Compare commits

..
Author SHA1 Message Date
orchestrator-agent 76a2b611c8 Feature 14 integration fix: revalidate the draft pool, refuse duplicate week dinners, refresh the Quest log (#122)
CI / ci (push) Successful in 3m43s
CI / ci (pull_request) Successful in 3m55s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 16:30:03 -04:00
orchestrator-agent 955613941f Drag-and-drop and keyboard swapping (Feature 14) (#120)
CI / ci (pull_request) Successful in 3m24s
CI / ci (push) Successful in 3m28s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 15:45:11 -04:00
orchestrator-agent dba4b93280 Tablet and desktop plan manager (2c, 2d) (#119)
CI / ci (push) Successful in 3m18s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 14:22:48 -04:00
orchestrator-agent 3aef21922b Mobile swap sheet (Feature 14) (#118)
CI / ci (push) Successful in 3m13s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 13:28:03 -04:00
orchestrator-agent 9b9421bb58 Mobile draft board (1f) (#117)
CI / ci (push) Successful in 3m21s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 12:42:54 -04:00
24 changed files with 3264 additions and 120 deletions
+8 -2
View File
@@ -357,13 +357,19 @@ automatic** (D-021).
left as it is and the job fails `confirm_required`. The job reads the left as it is and the job fails `confirm_required`. The job reads the
plan's revision before draft_week runs; if the plan was edited (a swap, plan's revision before draft_week runs; if the plan was edited (a swap,
or another draft stored) by the time the draft is stored, the plan is or another draft stored) by the time the draft is stored, the plan is
left as it is and the job fails `plan_changed`. A pool under seven left as it is and the job fails `plan_changed`. The pool is read again
in the same transaction that stores the draft; if a dinner the draft
planned has left it meanwhile (archived, `tried`, or a Quest log dinner
stored on another current or later week's plan, as by a draft of
another week), nothing is stored and the job fails `pool_changed`,
which drafting again resolves. A pool under seven
dinners fails the job `not_enough_dinners`, draft_week failing dinners fails the job `not_enough_dinners`, draft_week failing
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes `ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes `{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
`is_new`, moves a `synced` plan to `changed`, and answers 200 with the `is_new`, moves a `synced` plan to `changed`, and answers 200 with the
plan. A date outside the week is 400 `invalid_date`, an unknown dinner or plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
a week with no plan 404, and any other dinner 409 `dinner_not_plannable`. a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
a dinner already on another day of the week 409 `dinner_already_planned`.
- Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the - Rotation check (`plan.Service`, D-021). `MarkTried(date)`, which the
`rotation_check` schedule calls for the household-local day just past, `rotation_check` schedule calls for the household-local day just past,
moves every `quest_log` dinner planned on that date, on a plan of any moves every `quest_log` dinner planned on that date, on a plan of any
+23 -6
View File
@@ -36,9 +36,9 @@ func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// Regulars and the Quest log dinners no other current or later plan has, // Regulars and the Quest log dinners no other current or later plan has,
// given the household context and last week's dinners, and the draft is // given the household context and last week's dinners, and the draft is
// stored as the week's plan if the plan is still as it was before // stored as the week's plan if the plan is still as it was before
// draft_week ran. A pool under seven dinners fails with // draft_week ran and every dinner it planned is still in the pool. A pool
// ReasonNotEnoughDinners and draft_week failing with ReasonAIFailed; no // under seven dinners fails with ReasonNotEnoughDinners and draft_week
// week is made up instead. // failing with ReasonAIFailed; no week is made up instead.
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) { func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
weekStart, err := ParseWeekStart(p.WeekStart) weekStart, err := ParseWeekStart(p.WeekStart)
if err != nil { if err != nil {
@@ -68,7 +68,7 @@ func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult)
for _, w := range week.Warnings { for _, w := range week.Warnings {
s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w) s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w)
} }
planID, err := s.storeDraft(ctx, weekStart, p.Confirm, base, week) planID, err := s.storeDraft(ctx, weekStart, household.WeekStart(in.Context.Today), p.Confirm, base, week)
if err != nil { if err != nil {
return draftResult{}, err return draftResult{}, err
} }
@@ -142,11 +142,28 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill
// draft was asked for without confirm is left as it is, failing with // draft was asked for without confirm is left as it is, failing with
// ReasonConfirmRequired, and so is a plan no longer at revision base (the // ReasonConfirmRequired, and so is a plan no longer at revision base (the
// revision before draft_week ran, or noPlan), failing with // revision before draft_week ran, or noPlan), failing with
// ReasonPlanChanged. // ReasonPlanChanged. The pool is read again, as of thisWeek like the pool
func (s *Service) storeDraft(ctx context.Context, weekStart store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) { // draft_week was given: a day whose dinner is no longer in it, or is in it
// as a Regular rather than the Quest log or the other way round, leaves
// everything as it is, failing with ReasonPoolChanged.
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
var planID string var planID string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error { err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx) q := db.New(tx)
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
if err != nil {
return fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make(map[string]string, len(rows))
for _, d := range rows {
pool[d.ID] = d.Status
}
for _, d := range week.Days {
status, ok := pool[d.DinnerID]
if !ok || (status == dinners.StatusQuestLog) != d.IsNew {
return &jobs.Failure{Code: ReasonPoolChanged}
}
}
now := store.Now().String() now := store.Now().String()
note := sql.NullString{String: week.Note, Valid: week.Note != ""} note := sql.NullString{String: week.Note, Valid: week.Note != ""}
cur, err := q.GetWeekPlan(ctx, weekStart.String()) cur, err := q.GetWeekPlan(ctx, weekStart.String())
+21 -4
View File
@@ -53,10 +53,15 @@ const (
// ReasonPlanChanged: the plan was edited while draft_week ran, so the // ReasonPlanChanged: the plan was edited while draft_week ran, so the
// draft does not replace the edit. // draft does not replace the edit.
ReasonPlanChanged = "plan_changed" ReasonPlanChanged = "plan_changed"
// ReasonPoolChanged: a dinner the draft planned left the pool while
// draft_week ran (archived, tried, or its Quest log dinner planned on
// another week), so the draft is not stored; drafting again uses the
// pool as it is now.
ReasonPoolChanged = "pool_changed"
) )
// Reasons are every draft failure reason. // Reasons are every draft failure reason.
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged} var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged}
// ContextBudget is the character budget of the household context // ContextBudget is the character budget of the household context
// draft_week is given (see harness.BuildContext). // draft_week is given (see harness.BuildContext).
@@ -71,6 +76,10 @@ var ErrNotFound = errors.New("plan not found")
// ErrDinnerNotFound is a swap to an unknown dinner. // ErrDinnerNotFound is a swap to an unknown dinner.
var ErrDinnerNotFound = errors.New("dinner not found") var ErrDinnerNotFound = errors.New("dinner not found")
// ErrDinnerAlreadyPlanned is a swap to a dinner already on another day of
// the week.
var ErrDinnerAlreadyPlanned = errors.New("the dinner is already on another day of this week")
// ErrConfirmRequired is a draft over a synced or changed plan asked for // ErrConfirmRequired is a draft over a synced or changed plan asked for
// without confirm. // without confirm.
var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm") var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm")
@@ -337,9 +346,10 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
// Swap plans dinnerID on date of the week starting weekStart, marking the // Swap plans dinnerID on date of the week starting weekStart, marking the
// day new when the dinner is in the Quest log. Swapping a synced plan // day new when the dinner is in the Quest log. Swapping a synced plan
// makes it changed. The week must have a plan (else ErrNotFound), date // makes it changed. The week must have a plan (else ErrNotFound), date
// must be one of its days (else *InvalidDayError), and the dinner must be // must be one of its days (else *InvalidDayError), the dinner must be a
// a Regular or in the Quest log (else ErrDinnerNotFound or // Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError). It returns the plan as swapped. // *DinnerNotPlannableError), and it must not be on another day of the
// week (else ErrDinnerAlreadyPlanned). It returns the plan as swapped.
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) { func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
dates, err := household.WeekDays(weekStart) dates, err := household.WeekDays(weekStart)
if err != nil { if err != nil {
@@ -369,6 +379,13 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
if !slices.Contains(plannable, d.Status) { if !slices.Contains(plannable, d.Status) {
return &DinnerNotPlannableError{Status: d.Status} return &DinnerNotPlannableError{Status: d.Status}
} }
days, err := q.ListPlanDays(ctx, cur.ID)
if err != nil {
return fmt.Errorf("plan: read days of %s: %w", cur.ID, err)
}
if slices.ContainsFunc(days, func(r db.ListPlanDaysRow) bool { return r.Date != day.String() && r.Dinner.ID == d.ID }) {
return ErrDinnerAlreadyPlanned
}
now := store.Now().String() now := store.Now().String()
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{ n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
DinnerID: sql.NullString{String: d.ID, Valid: true}, DinnerID: sql.NullString{String: d.ID, Valid: true},
+81 -7
View File
@@ -174,9 +174,15 @@ func (e *env) reply(note string, names ...string) {
// weekReply is the draft_week reply planning the dinners named names on // weekReply is the draft_week reply planning the dinners named names on
// weekDates, in order. // weekDates, in order.
func (e *env) weekReply(note string, names ...string) llm.GenerateResult { func (e *env) weekReply(note string, names ...string) llm.GenerateResult {
return e.weekReplyOn(weekDates, note, names...)
}
// weekReplyOn is the draft_week reply planning the dinners named names on
// dates, in order.
func (e *env) weekReplyOn(dates []string, note string, names ...string) llm.GenerateResult {
var days []string var days []string
for i, name := range names { for i, name := range names {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, weekDates[i], e.ids[name])) days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], e.ids[name]))
} }
js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note) js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}} return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
@@ -398,15 +404,16 @@ func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) {
t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want) t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want)
} }
} }
// Swapped to Beef Chili, Sunday starts at 13:30 EST. // Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST.
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", e.ids["Beef Chili"]); err != nil { ribs := e.dinner(t, "Short Ribs", "regular", "crockpot", 240)
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", ribs); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if p, err = e.svc.Get(t.Context(), dst); err != nil { if p, err = e.svc.Get(t.Context(), dst); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` { if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` {
t.Errorf("Sunday Beef Chili start_by = %s, %v; want 13:30 EST", b, err) t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", b, err)
} }
} }
@@ -466,6 +473,70 @@ func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) {
} }
} }
func TestDraftFailsWhenPoolDinnerLeavesWhileDrafting(t *testing.T) {
for _, status := range []string{"archived", "tried"} {
t.Run(status, func(t *testing.T) {
e := newEnv(t)
e.start(t)
// Pad Thai leaves the Quest log while draft_week plans it.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = ? WHERE id = ?`, status, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
})
}
}
func TestDraftsOfTwoWeeksDoNotShareAQuestLogDinner(t *testing.T) {
e := newEnv(t)
e.start(t)
// This week's draft plans Pad Thai; while draft_week runs, the other
// worker drafts a later week with Pad Thai too and stores it first.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
later := mustDate("2099-01-05")
laterDates := []string{"2099-01-05", "2099-01-06", "2099-01-07", "2099-01-08", "2099-01-09", "2099-01-10", "2099-01-11"}
e.model.QueueGenerate(e.weekReplyOn(laterDates, "", fullWeek...))
other, err := e.svc.StartDraft(t.Context(), later, false)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, other.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("later week's draft %s %v", job.Status, job.Error)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
}
func TestDraftFailsWithNotEnoughDinners(t *testing.T) { func TestDraftFailsWithNotEnoughDinners(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.start(t) e.start(t)
@@ -633,7 +704,8 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
<-started <-started
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Sheet Pan Salmon"]); err != nil { e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil {
t.Fatal(err) t.Fatal(err)
} }
close(release) close(release)
@@ -646,7 +718,7 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
want := slices.Clone(fullWeek) want := slices.Clone(fullWeek)
want[2] = "Sheet Pan Salmon" want[2] = "Fish Tacos"
if *p.AINote != "first" || !slices.Equal(dayNames(p), want) { if *p.AINote != "first" || !slices.Equal(dayNames(p), want) {
t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want) t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want)
} }
@@ -822,7 +894,8 @@ func TestSwapStatusTransitions(t *testing.T) {
e.reply("", fullWeek...) e.reply("", fullWeek...)
e.mustDraft(t, false) e.mustDraft(t, false)
e.setStatus(t, tc.from) e.setStatus(t, tc.from)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", e.ids["Pad Thai"]) fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -852,6 +925,7 @@ func TestSwapRefusals(t *testing.T) {
}{ }{
{"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }}, {"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }},
{"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }}, {"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }},
{"dinner on another day", week, "2026-10-12", e.ids["Pot Roast"], func(err error) bool { return errors.Is(err, plan.ErrDinnerAlreadyPlanned) }},
{"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, &notPlannable) }}, {"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, &notPlannable) }},
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, &notPlannable) }}, {"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, &notPlannable) }},
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }}, {"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
+4 -2
View File
@@ -107,8 +107,8 @@ func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger
// writePlanError maps a plan.Service error to its API error: a week with // writePlanError maps a plan.Service error to its API error: a week with
// no plan or an unknown dinner is 404, a date outside the week is 400, and // no plan or an unknown dinner is 404, a date outside the week is 400, and
// drafting over a synced plan without confirm, a confirmed draft while an // drafting over a synced plan without confirm, a confirmed draft while an
// unconfirmed one runs, or planning a dinner that is neither a Regular nor // unconfirmed one runs, planning a dinner that is neither a Regular nor in
// in the Quest log, is 409. // the Quest log, or one already on another day of the week, is 409.
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var day *plan.InvalidDayError var day *plan.InvalidDayError
var week *plan.InvalidWeekError var week *plan.InvalidWeekError
@@ -120,6 +120,8 @@ func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger,
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error()) writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
case errors.As(err, &notPlannable): case errors.As(err, &notPlannable):
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error()) writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
case errors.Is(err, plan.ErrDinnerAlreadyPlanned):
writeError(w, r, logger, http.StatusConflict, "dinner_already_planned", err.Error())
case errors.Is(err, plan.ErrConfirmRequired): case errors.Is(err, plan.ErrConfirmRequired):
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error()) writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
case errors.Is(err, plan.ErrDraftRunning): case errors.Is(err, plan.ErrDraftRunning):
+1
View File
@@ -309,6 +309,7 @@ func TestPutPlanDay(t *testing.T) {
{"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"}, {"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"}, {"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"},
{"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"}, {"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"},
{"2026-10-12/days/2026-10-15", `{"dinner_id":"` + e.questLog + `"}`, http.StatusConflict, "dinner_already_planned"},
} { } {
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body) rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body)
if rec.Code != tc.status || errorCode(body) != tc.code { if rec.Code != tc.status || errorCode(body) != tc.code {
+1 -1
View File
@@ -20,7 +20,7 @@ export type QuestLogSheetProps = {
// details is a Quest-log dinner's "difficulty · minutes" line, or null when // details is a Quest-log dinner's "difficulty · minutes" line, or null when
// neither is known. // neither is known.
function details(dinner: QuestLogDinner): string | null { export function details(dinner: QuestLogDinner): string | null {
const parts = [ const parts = [
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty], dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
dinner.minutes_total !== null && `${dinner.minutes_total} min`, dinner.minutes_total !== null && `${dinner.minutes_total} min`,
+8 -6
View File
@@ -51,8 +51,9 @@ export const BATCH_JOB_KIND = 'suggestion_batch'
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number } export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists // QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
// it: photo, name, difficulty and minutes. // it: photo, name, difficulty and minutes, with the method a swap carries
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'minutes_total'> // onto the plan.
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'method' | 'minutes_total'>
// DECISION_STATUS is the status a suggestion has once each choice is saved. // DECISION_STATUS is the status a suggestion has once each choice is saved.
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' } const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
@@ -127,8 +128,8 @@ export function useQuestLog() {
const body = await apiFetch<unknown>(QUEST_LOG_PATH) const body = await apiFetch<unknown>(QUEST_LOG_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`) if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner) => { return body.dinners.map((dinner) => {
const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner) const { id, name, photo_id, difficulty, method, minutes_total } = parseDinner(where, dinner)
return { id, name, photo_id, difficulty, minutes_total } return { id, name, photo_id, difficulty, method, minutes_total }
}) })
}, },
}) })
@@ -250,13 +251,14 @@ export function useDecide() {
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) => queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id) dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
? dinners ? dinners
: // The deck does not say the minutes; the Quest log read after the : // The deck does not say the method or minutes; the Quest log
// decision does. // read after the decision does.
dinners.concat({ dinners.concat({
id: quest.dinner_id, id: quest.dinner_id,
name: quest.name, name: quest.name,
photo_id: quest.photo_id, photo_id: quest.photo_id,
difficulty: quest.difficulty, difficulty: quest.difficulty,
method: null,
minutes_total: null, minutes_total: null,
}), }),
) )
+362 -15
View File
@@ -1,13 +1,40 @@
/* Plan · Draft board (mock 1f). */ /* Plan · Draft board (mock 1f) and plan manager (mocks 2c, 2d). */
.page { .page {
box-sizing: border-box; box-sizing: border-box;
max-width: 440px; }
.board {
box-sizing: border-box;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
}
.mobilePage {
max-width: 440px;
padding-top: 8px; padding-top: 8px;
} }
/* The grid fills the screen below the header: the main area's 32 px top and
bottom padding (AppShell) is taken back off the viewport height. */
.tabletPage .board {
min-height: calc(100dvh - 64px);
}
/* Desktop: the board beside the 290 px right panel, which runs the full
height of the screen to its right edge over the main area's padding. */
.desktopPage {
display: grid;
grid-template-columns: minmax(0, 1fr) 290px;
min-height: 100dvh;
margin: -32px -36px -32px 0;
}
.desktopPage .board {
padding: 32px 36px 32px 0;
}
.header { .header {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -95,22 +122,38 @@
.tile { .tile {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column;
justify-content: space-between;
gap: 6px;
min-height: 86px; min-height: 86px;
padding: 12px;
border-radius: 22px; border-radius: 22px;
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text); color: var(--color-text);
} }
/* The whole tile is the button that opens the swap sheet. */
.tileButton {
box-sizing: border-box;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
gap: 6px;
padding: 12px;
border: 0;
border-radius: inherit;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
/* A Quest-log pick. */ /* A Quest-log pick. */
.newTile { .newTile {
background: var(--color-accent-2-300); background: var(--color-accent-2-300);
} }
.dayKicker { .dayKicker {
display: block;
margin: 0; margin: 0;
font-family: var(--font-body); font-family: var(--font-body);
font-size: 10px; font-size: 10px;
@@ -125,6 +168,7 @@
} }
.dinner { .dinner {
display: block;
margin: 0; margin: 0;
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: 15px; font-size: 15px;
@@ -134,6 +178,7 @@
} }
.startBy { .startBy {
display: block;
margin: 4px 0 0; margin: 4px 0 0;
font-family: var(--font-body); font-family: var(--font-body);
font-size: 11px; font-size: 11px;
@@ -145,6 +190,22 @@
color: var(--color-accent-2-900); color: var(--color-accent-2-900);
} }
/* The day being swapped, while the swap sheet is open: the page background
ringed in grape, its name muted. */
.swapping {
background: var(--color-bg);
box-shadow: inset 0 0 0 2px var(--color-accent);
}
.swapping .dayKicker {
color: var(--color-accent-700);
}
.swapping .dinner,
.swapping .startBy {
color: var(--color-neutral-600);
}
.rebuildCell { .rebuildCell {
display: flex; display: flex;
} }
@@ -168,10 +229,15 @@
cursor: pointer; cursor: pointer;
} }
.rebuild:hover { .rebuild:hover:not(:disabled) {
background: var(--color-neutral-100); background: var(--color-neutral-100);
} }
.rebuild:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.loading { .loading {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -192,9 +258,10 @@
color: var(--color-neutral-700); color: var(--color-neutral-700);
} }
/* "Looks good — push to Calendar", unavailable until Feature 16: it keeps /* An action with nothing behind it yet (Push to Calendar until Feature 16,
focus, so hovering, tapping or focusing it shows the tooltip. */ the shopping list until Feature 17): it keeps focus, so hovering, tapping
.push { or focusing it shows the tooltip. */
.unavailable {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -204,18 +271,24 @@
min-height: 50px; min-height: 50px;
} }
.pushButton[aria-disabled='true'] { .unavailable [aria-disabled='true'] {
opacity: 0.45; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
} }
/* Unavailable, it neither darkens nor presses. */ /* Unavailable, it neither darkens nor presses. */
.push .pushButton[aria-disabled='true']:hover, .unavailable .primary[aria-disabled='true']:hover,
.push .pushButton[aria-disabled='true']:active { .unavailable .primary[aria-disabled='true']:active {
background: var(--color-accent); background: var(--color-accent);
transform: none; transform: none;
} }
.unavailable .outline[aria-disabled='true']:hover,
.unavailable .outline[aria-disabled='true']:active {
background: transparent;
transform: none;
}
.hint { .hint {
position: absolute; position: absolute;
bottom: calc(100% + 8px); bottom: calc(100% + 8px);
@@ -236,7 +309,281 @@
pointer-events: none; pointer-events: none;
} }
.push:hover .hint, /* In the plan manager's header the tooltip opens below, at the action's
.pushButton:focus + .hint { right edge. */
.below .hint {
top: calc(100% + 8px);
bottom: auto;
left: auto;
right: 0;
transform: none;
width: 240px;
max-width: none;
z-index: 1;
}
.unavailable:hover .hint,
.unavailable [aria-disabled='true']:focus + .hint {
visibility: visible; visibility: visible;
} }
/* The plan manager's header: kicker and title beside the week buttons, over
the sync status and the actions. */
.wideHeader {
display: flex;
flex-direction: column;
gap: 14px;
}
.titleBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.wideTitle {
margin: 4px 0 0;
font-family: var(--font-heading);
font-size: 40px;
font-weight: 400;
line-height: 1.1;
}
.tabletPage .wideTitle {
font-size: 36px;
}
.wideHeader .kicker {
font-size: 12px;
}
.wideHeader .weekNav {
align-items: center;
gap: 12px;
}
.thisWeek {
min-height: 44px;
padding: 10px 16px;
border: 0;
border-radius: var(--radius-pill);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.thisWeek:hover {
background: var(--color-neutral-100);
}
.actionBar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
/* The status keeps to one line; the actions wrap under it when the row is
too narrow for both. */
.status {
flex: 1 0 auto;
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-neutral-700);
}
.synced {
color: var(--color-accent-2-800);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.action {
min-height: 44px;
padding: 0 18px;
white-space: nowrap;
}
/* The week: seven columns, each a day label over its card. A column is
never narrower than 112 px, which holds a dinner's words whole and the
tag beside the swap icon; where seven of those do not fit (tablet's
narrow end, desktop beside the panel) the week scrolls sideways. Its 8 px
padding, taken back by the margin, keeps the swap icons' hit boxes and
focus rings inside the scroll area. */
.week {
flex: 1;
display: grid;
grid-template-columns: repeat(7, minmax(112px, 1fr));
gap: 12px;
margin: -8px;
padding: 8px;
overflow-x: auto;
list-style: none;
}
.column {
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.dayLabel {
display: flex;
align-items: baseline;
gap: 6px;
margin: 0;
padding: 0 4px;
}
.dayName {
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.dayNumber {
font-family: var(--font-heading);
font-size: 22px;
color: var(--color-text);
}
.card {
position: relative;
box-sizing: border-box;
flex: 1;
display: flex;
flex-direction: column;
gap: 9px;
padding: 12px;
border-radius: 26px;
background: var(--color-surface);
color: var(--color-text);
}
.tablet .card {
gap: 8px;
padding: 10px;
border-radius: 24px;
}
/* A Side quest's card. */
.sideQuestCard {
background: var(--color-accent-2-200);
}
.cardPhoto {
width: 100%;
height: 110px;
}
.tablet .cardPhoto {
height: 96px;
}
.cardName {
margin: 0;
font-family: var(--font-heading);
font-size: 17px;
line-height: 1.15;
overflow-wrap: break-word;
}
.tablet .cardName {
font-size: 16px;
}
.cardMinutes,
.cardStartBy {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
color: var(--color-neutral-800);
}
.cardStartBy {
font-weight: 700;
}
.sideQuestCard .cardMinutes,
.sideQuestCard .cardStartBy {
color: var(--color-accent-2-900);
}
.cardFooter {
margin-top: auto;
display: flex;
align-items: center;
gap: 4px;
}
/* The day a dragged dinner drops on: a dashed 2.5 px grape border on
grape-100 over the card, saying which dinner it replaces. */
.dropTarget {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
padding: 12px;
border: 2.5px dashed var(--color-accent);
border-radius: inherit;
background: var(--color-accent-100);
text-align: center;
}
.dropTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 16px;
color: var(--color-accent-700);
}
.dropReplaces {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
overflow-wrap: break-word;
}
/* The swap icon sits bare on the card, beside the tag: its 44 px hit box
keeps the 16 px footprint of its icon, reaching over the card's padding
and the tag. */
.cardFooter .swapButton {
margin: -14px;
}
.cardFooter .cardTag {
margin-right: auto;
padding: 3px 8px;
}
.swapButton > span {
background: transparent;
color: var(--color-neutral-700);
}
.skeletonCard {
height: 300px;
border-radius: 26px;
}
File diff suppressed because it is too large Load Diff
+367 -61
View File
@@ -1,18 +1,47 @@
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react' import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
import { useEffect, useId, useMemo, useState } from 'react' import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { useJob } from '../api/index.ts' import { useJob } from '../api/index.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx' import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { Button } from '../ui/Button.tsx' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
import { EmptyState } from '../ui/EmptyState.tsx' import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx' import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx' import { IconButton } from '../ui/IconButton.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Skeleton } from '../ui/Skeleton.tsx' import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx' import { Tag } from '../ui/Tag.tsx'
import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type DraftResult, type PlanDay, type PlanStatus } from './api.ts' import {
import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } from './week.ts' DRAFT_JOB_KIND,
parseDraftResult,
planKey,
usePlan,
useStartDraft,
useSwapDay,
type DraftResult,
type Plan,
type PlanDay,
type PlanDinner,
type PlanStatus,
type Swap,
} from './api.ts'
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx'
import { SwapSheet } from './SwapSheet.tsx'
import {
addDays,
dayOfMonth,
defaultWeek,
localNow,
weekdayKicker,
weekdayName,
weekLabel,
weekStart as weekOf,
} from './week.ts'
import styles from './PlanPage.module.css' import styles from './PlanPage.module.css'
// statusTags is the tag beside the week's title for each plan status. // statusTags is the tag beside the week's title for each plan status.
@@ -29,6 +58,7 @@ const draftFailures: Record<string, string> = {
ai_failed: "The AI assistant couldn't draft it.", ai_failed: "The AI assistant couldn't draft it.",
confirm_required: 'The plan was pushed to Calendar while it was drafting.', confirm_required: 'The plan was pushed to Calendar while it was drafting.',
plan_changed: 'The plan was edited while it was drafting.', plan_changed: 'The plan was edited while it was drafting.',
pool_changed: 'A dinner it picked was archived, tried or planned for another week while it was drafting.',
internal_error: 'Something went wrong on the server.', internal_error: 'Something went wrong on the server.',
} }
@@ -36,6 +66,48 @@ const draftFailures: Record<string, string> = {
// TODO(Feature 16): connect Push to Calendar once Google Calendar is. // TODO(Feature 16): connect Push to Calendar once Google Calendar is.
const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.' const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.'
// SHOPPING_HINT is why the shopping list is unavailable.
// TODO(Feature 17): open the Supply run, with its count, once it is built.
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
const syncStatuses: Record<PlanStatus, string> = {
draft: 'Not pushed to Google Calendar yet',
synced: 'Synced to Google Calendar',
changed: 'Changes not pushed to Google Calendar',
}
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
// between the method and the duration, never inside the duration.
export function duration(minutes: number): string {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (hours === 0) return `${rest}\u00a0min`
return rest === 0 ? `${hours}\u00a0hr` : `${hours}\u00a0hr\u00a0${rest}\u00a0min`
}
// cooking is a card's "method · duration" line, "Crock pot · 6 hr", with
// whichever of the two is known, or null when neither is.
export function cooking(dinner: PlanDinner): string | null {
const parts = [dinner.method && methodLabels[dinner.method], dinner.minutes !== null && duration(dinner.minutes)].filter(
(part): part is string => typeof part === 'string',
)
return parts.length === 0 ? null : parts.join(' · ')
}
// ago is how long before now the instant at was: "just now", "2 min ago",
// "3 hr ago", "2 days ago".
export function ago(at: Date, now: Date): string {
const minutes = Math.floor((now.getTime() - at.getTime()) / 60_000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes} min ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} hr ago`
const days = Math.floor(hours / 24)
return days === 1 ? '1 day ago' : `${days} days ago`
}
// pushedToCalendar says whether drafting over a plan of this status needs // pushedToCalendar says whether drafting over a plan of this status needs
// confirm: it was pushed to Calendar. // confirm: it was pushed to Calendar.
function pushedToCalendar(status: PlanStatus): boolean { function pushedToCalendar(status: PlanStatus): boolean {
@@ -44,16 +116,66 @@ function pushedToCalendar(status: PlanStatus): boolean {
// DayTile is one day of the Draft board: a Regular on the surface, a // DayTile is one day of the Draft board: a Regular on the surface, a
// Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's // Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's
// start-by time in the household's zone. // start-by time in the household's zone. Tapping it opens the swap sheet;
function DayTile({ day, timezone }: { day: PlanDay; timezone: string }) { // while that is open the tile is swapping: on the page background, ringed in
// grape, its name muted.
function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
const kicker = weekdayKicker(day.date) const kicker = weekdayKicker(day.date)
const classes = [styles.tile, day.is_new && styles.newTile, swapping && styles.swapping].filter(Boolean).join(' ')
return ( return (
<li className={[styles.tile, day.is_new && styles.newTile].filter(Boolean).join(' ')}> <li className={classes}>
<p className={styles.dayKicker}>{day.is_new ? `${kicker} · NEW` : kicker}</p> <button type="button" className={styles.tileButton} onClick={onSwap}>
<div> <span className={styles.dayKicker}>{swapping ? `${kicker} · SWAPPING…` : day.is_new ? `${kicker} · NEW` : kicker}</span>
<p className={styles.dinner}>{day.dinner.name}</p> <span>
<span className={styles.dinner}>{day.dinner.name}</span>
{day.start_by !== null && (
<span className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</span>
)}
</span>
</button>
</li>
)
}
// DayColumn is one day of the plan manager's 7-column grid: its day label
// over a card with the dinner's washed photo, name, method and time, a
// crock-pot dinner's start-by time in the household's zone, and its Regular
// or Side quest tag, a Side quest's card in teal. The swap icon opens the swap
// sheet; while that is open the card is swapping, ringed in grape. A dinner
// dragged over the day drops on it: the card is swapping, covered by a
// dashed grape "Drop to swap" saying which dinner it replaces.
function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
const { dinner } = day
const time = cooking(dinner)
const target = useDropTarget(day.date) !== null
const classes = [styles.card, day.is_new && styles.sideQuestCard, (swapping || target) && styles.swapping].filter(Boolean).join(' ')
return (
<li className={styles.column} aria-label={weekdayName(day.date)} {...{ [DROP_DATE]: day.date }}>
<p className={styles.dayLabel} aria-hidden="true">
<span className={styles.dayName}>{weekdayKicker(day.date)}</span>
<span className={styles.dayNumber}>{dayOfMonth(day.date)}</span>
</p>
<div className={classes}>
{/* The card's name already says what the photo shows. */}
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="18px" sizes="160px" className={styles.cardPhoto} />
</span>
<p className={styles.cardName}>{dinner.name}</p>
{time !== null && <p className={styles.cardMinutes}>{time}</p>}
{day.start_by !== null && ( {day.start_by !== null && (
<p className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p> <p className={styles.cardStartBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
)}
<div className={styles.cardFooter}>
<Tag tone={day.is_new ? 'grape' : 'neutral'} className={styles.cardTag}>{day.is_new ? 'Side quest' : 'Regular'}</Tag>
<IconButton label={`Swap ${weekdayName(day.date)}`} className={styles.swapButton} onClick={onSwap}>
<Icon icon={RefreshCw} size="inline" />
</IconButton>
</div>
{target && (
<div className={styles.dropTarget}>
<p className={styles.dropTitle}>Drop to swap</p>
<p className={styles.dropReplaces}>Replaces {dinner.name}</p>
</div>
)} )}
</div> </div>
</li> </li>
@@ -61,13 +183,14 @@ function DayTile({ day, timezone }: { day: PlanDay; timezone: string }) {
} }
// SkeletonTiles stands in for the seven days while the plan loads or the AI // SkeletonTiles stands in for the seven days while the plan loads or the AI
// drafts it. // drafts it, as two columns of tiles on mobile and seven columns wider.
function SkeletonTiles({ label, children }: { label: string; children?: string }) { function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
const wide = layout !== 'mobile'
return ( return (
<section aria-busy="true" aria-label={label} className={styles.loading}> <section aria-busy="true" aria-label={label} className={styles.loading}>
<div className={styles.grid}> <div className={wide ? `${styles.week} ${styles[layout]}` : styles.grid}>
{Array.from({ length: 7 }, (_, i) => ( {Array.from({ length: 7 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-tile" className={styles.skeletonTile} /> <Skeleton key={i} data-testid="skeleton-tile" className={wide ? styles.skeletonCard : styles.skeletonTile} />
))} ))}
</div> </div>
{children !== undefined && <p className={styles.drafting}>{children}</p>} {children !== undefined && <p className={styles.drafting}>{children}</p>}
@@ -80,13 +203,30 @@ function SkeletonTiles({ label, children }: { label: string; children?: string }
// starts the draft_week job and shows skeleton tiles until the job ends and // starts the draft_week job and shows skeleton tiles until the job ends and
// the plan is read again. Rebuilding a plan pushed to Calendar asks first, // the plan is read again. Rebuilding a plan pushed to Calendar asks first,
// and sends confirm. A failed draft is shown with Retry; no plan is made up. // and sends confirm. A failed draft is shown with Retry; no plan is made up.
function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezone: string; onWeek: (weekStart: string) => void }) { //
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the
// unavailable Shopping list and Push to Calendar are actions in the header,
// over the plan's sync status and the 7-column grid.
function WeekBoard({
weekStart,
timezone,
layout,
onWeek,
}: {
weekStart: string
timezone: string
layout: Breakpoint
onWeek: (weekStart: string) => void
}) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const plan = usePlan(weekStart) const plan = usePlan(weekStart)
const startDraft = useStartDraft() const startDraft = useStartDraft()
const [jobId, setJobId] = useState<string | null>(null) const [jobId, setJobId] = useState<string | null>(null)
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId) const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const swap = useSwapDay(weekStart)
// The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null)
const label = weekLabel(weekStart) const label = weekLabel(weekStart)
// The draft's result, once the job has succeeded, or why it cannot be read. // The draft's result, once the job has succeeded, or why it cannot be read.
@@ -101,12 +241,14 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
// Once the job ends the plan is read again: a succeeded draft is followed // Once the job ends the plan is read again: a succeeded draft is followed
// until that read lands; a failed one stays shown, and may have met a // until that read lands; a failed one stays shown, and may have met a
// plan changed meanwhile. // plan changed meanwhile. The Quest log is read again too, as after a
// swap: the draft may have planned some of it.
const [readJobId, setReadJobId] = useState<string | null>(null) const [readJobId, setReadJobId] = useState<string | null>(null)
const ended = job.status === 'succeeded' || job.status === 'failed' const ended = job.status === 'succeeded' || job.status === 'failed'
useEffect(() => { useEffect(() => {
if (!ended) return if (!ended) return
let active = true let active = true
void queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => { void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => {
if (active) setReadJobId(jobId) if (active) setReadJobId(jobId)
}) })
@@ -143,8 +285,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
const { mutate } = startDraft const { mutate } = startDraft
function start(confirm: boolean) { function start(confirm: boolean) {
setConfirming(false) setConfirming(false)
// A draft asked for again leaves the last one's failure behind. // A draft asked for again leaves the last one's failure, and a failed
// swap's, behind.
setJobId(null) setJobId(null)
swap.dismiss()
mutate( mutate(
{ weekStart, confirm }, { weekStart, confirm },
{ {
@@ -155,12 +299,19 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
) )
} }
// rebuild drafts the week, asking first over a plan pushed to Calendar. // rebuild drafts the week, asking first over a plan pushed to Calendar.
// It waits for a swap of the week still pending, which may yet fail: the
// Rebuild week tile is disabled meanwhile.
function rebuild() { function rebuild() {
if (swap.pending) return
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true) if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
else start(false) else start(false)
} }
const failure = startDraft.isError ? ( const failure = swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
</ErrorBanner>
) : startDraft.isError ? (
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner> <ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
) : result.error !== null ? ( ) : result.error !== null ? (
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner> <ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner>
@@ -174,16 +325,40 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner> <ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
) )
// swapIn sends the swap chosen in the sheet and closes it.
function swapIn(chosen: Swap) {
setSwappingDate(null)
swap.mutate(chosen)
}
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
// nor dropped, while a swap of the week is pending.
function dropIn(chosen: Swap) {
swap.dismiss()
swapIn(chosen)
}
// openSwap opens the swap sheet on a day. One swap of the week is sent at
// a time, even across leaving the week and coming back: the plan the
// server answers it with must not overwrite another still pending.
function openSwap(date: string) {
if (swap.pending) return
swap.dismiss()
setSwappingDate(date)
}
let body let body
if (plan.isError) { if (plan.isError) {
body = <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load this week's plan. {plan.error.message}</ErrorBanner> body = <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load this week's plan. {plan.error.message}</ErrorBanner>
} else if (drafting) { } else if (drafting) {
body = <SkeletonTiles label="Drafting this week">Drafting your week…</SkeletonTiles> body = (
<SkeletonTiles label="Drafting this week" layout={layout}>
Drafting your week…
</SkeletonTiles>
)
} else if (resultError !== null) { } else if (resultError !== null) {
// An unread draft shows no board: the plan read is not the draft. // An unread draft shows no board: the plan read is not the draft.
body = null body = null
} else if (plan.isPending) { } else if (plan.isPending) {
body = <SkeletonTiles label="Plan loading" /> body = <SkeletonTiles label="Plan loading" layout={layout} />
} else if (plan.data === null) { } else if (plan.data === null) {
body = ( body = (
<EmptyState title="Nothing planned yet" action={{ label: 'Draft this week', onClick: rebuild }}> <EmptyState title="Nothing planned yet" action={{ label: 'Draft this week', onClick: rebuild }}>
@@ -192,35 +367,109 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
) )
} else { } else {
const { ai_note, days } = plan.data const { ai_note, days } = plan.data
body = ( const swappingDay = days.find((day) => day.date === swappingDate)
<> const sheet = swappingDay !== undefined && (
{ai_note !== null && ( <SwapSheet
<div className={styles.assistant}> day={swappingDay}
<span className={styles.avatar} aria-hidden="true"> planned={new Set(days.map((day) => day.dinner.id))}
Q onSwap={swapIn}
</span> onClose={() => setSwappingDate(null)}
<p className={styles.bubble}>{ai_note}</p> />
</div>
)}
<ul className={styles.grid} aria-label="Dinners this week">
{days.map((day) => (
<DayTile key={day.date} day={day} timezone={timezone} />
))}
<li className={styles.rebuildCell}>
<button type="button" className={styles.rebuild} onClick={rebuild}>
<Icon icon={Plus} size="inline" />
Rebuild week
</button>
</li>
</ul>
<PushToCalendar />
</>
) )
body =
layout !== 'mobile' ? (
<>
<DropBoard days={days} pending={swap.pending} drop={dropIn} />
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
{days.map((day) => (
<DayColumn
key={day.date}
day={day}
timezone={timezone}
swapping={day.date === swappingDate}
onSwap={() => openSwap(day.date)}
/>
))}
</ul>
{sheet}
</>
) : (
<>
{ai_note !== null && (
<div className={styles.assistant}>
<span className={styles.avatar} aria-hidden="true">
Q
</span>
<p className={styles.bubble}>{ai_note}</p>
</div>
)}
<ul className={styles.grid} aria-label="Dinners this week">
{days.map((day) => (
<DayTile
key={day.date}
day={day}
timezone={timezone}
swapping={day.date === swappingDate}
onSwap={() => openSwap(day.date)}
/>
))}
<li className={styles.rebuildCell}>
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
<Icon icon={Plus} size="inline" />
Rebuild week
</button>
</li>
</ul>
<Unavailable hint={PUSH_HINT} className={styles.pushButton}>
Looks good — push to Calendar
</Unavailable>
{sheet}
</>
)
} }
const tag = plan.data ? statusTags[plan.data.status] : null const tag = plan.data ? statusTags[plan.data.status] : null
return ( const header =
<> layout !== 'mobile' ? (
<header className={styles.wideHeader}>
<div className={styles.titleBar}>
<div className={styles.heading}>
<p className={styles.kicker}>{label.week} · Plan</p>
<h1 className={styles.wideTitle}>{label.dates}</h1>
</div>
<div className={styles.weekNav}>
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
<Icon icon={ChevronLeft} size="inline" />
</IconButton>
{layout === 'desktop' && (
<button type="button" className={styles.thisWeek} onClick={() => onWeek(weekOf(localNow(timezone, new Date()).date))}>
This week
</button>
)}
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
<Icon icon={ChevronRight} size="inline" />
</IconButton>
</div>
</div>
<div className={styles.actionBar}>
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
<div className={styles.actions}>
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
<Icon icon={ShoppingCart} size="inline" />
Shopping list
</Unavailable>
<Button variant="outline" className={styles.action} onClick={rebuild} disabled={swap.pending || drafting || !plan.isSuccess}>
<Icon icon={RefreshCw} size="inline" />
Rebuild week
</Button>
<Unavailable hint={PUSH_HINT} below className={styles.action}>
<Icon icon={CalendarDays} size="inline" />
Push to Calendar
</Unavailable>
</div>
</div>
</header>
) : (
<header className={styles.header}> <header className={styles.header}>
<div className={styles.heading}> <div className={styles.heading}>
<p className={styles.kicker}>{label.week}</p> <p className={styles.kicker}>{label.week}</p>
@@ -238,6 +487,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
</IconButton> </IconButton>
</div> </div>
</header> </header>
)
return (
<>
{header}
{body} {body}
{failure} {failure}
<Confirm <Confirm
@@ -254,27 +507,69 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
) )
} }
// PushToCalendar is "Looks good — push to Calendar", unavailable until // SyncStatus is the plan manager's status line: whether the plan is on
// Google Calendar can be connected: it stays focusable, so a tap or focus // Google Calendar and, once synced, how long ago it was pushed, kept current
// shows the tooltip saying why. // minute by minute.
function PushToCalendar() { function SyncStatus({ plan }: { plan: Plan }) {
const { status, calendar_synced_at: syncedAt } = plan
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 60_000)
return () => clearInterval(timer)
}, [])
return (
<p className={[styles.status, status === 'synced' && styles.synced].filter(Boolean).join(' ')}>
{status === 'synced' && <Icon icon={Check} size="inline" />}
{syncStatuses[status]}
{status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}
</p>
)
}
// Unavailable is an action with nothing behind it yet (Push to Calendar, the
// shopping list): it stays focusable, so a tap or focus shows the tooltip
// saying why, above it or, at the top of the page, below.
function Unavailable({
hint,
variant = 'primary',
below = false,
className,
children,
}: {
hint: string
variant?: ButtonVariant
below?: boolean
className?: string
children: ReactNode
}) {
const hintId = useId() const hintId = useId()
return ( return (
<div className={styles.push}> <div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
<Button className={styles.pushButton} aria-disabled="true" aria-describedby={hintId}> <Button
Looks good — push to Calendar variant={variant}
className={[className, styles[variant]].filter(Boolean).join(' ')}
aria-disabled="true"
aria-describedby={hintId}
>
{children}
</Button> </Button>
<span id={hintId} role="tooltip" className={styles.hint}> <span id={hintId} role="tooltip" className={styles.hint}>
{PUSH_HINT} {hint}
</span> </span>
</div> </div>
) )
} }
// PlanPage is mock 1f, the mobile Draft board, on the week chosen with the // PlanPage is the Draft board on the week chosen with the previous / next
// previous / next week buttons. It opens on next week once this week's // week buttons: mock 1f on mobile; from tablet up the plan manager, with the
// plan_draft time has come in the household's zone, otherwise on this week. // Quest log as a strip under the grid on tablet (2c) and the Quest log and
// Regulars in a right panel on desktop (2d). It opens on next week once this
// week's plan_draft time has come in the household's zone, otherwise on this
// week. The board keeps its place in the tree across layouts, so crossing a
// breakpoint keeps a draft being followed. From tablet up a dinner is
// dragged from the strip or the panel onto a day of the grid to swap it in.
export function PlanPage() { export function PlanPage() {
const layout = useBreakpoint()
const settings = useHouseholdSettings() const settings = useHouseholdSettings()
const [week, setWeek] = useState<string | null>(null) const [week, setWeek] = useState<string | null>(null)
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date())) if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
@@ -285,9 +580,20 @@ export function PlanPage() {
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner> <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
) )
} else if (week === null || !settings.data) { } else if (week === null || !settings.data) {
content = <SkeletonTiles label="Plan loading" /> content = <SkeletonTiles label="Plan loading" layout={layout} />
} else { } else {
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} onWeek={setWeek} /> content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
} }
return <div className={styles.page}>{content}</div> return (
<PlanDragProvider>
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
<div className={styles.board}>
{content}
{layout === 'tablet' && <QuestLogStrip />}
</div>
{layout === 'desktop' && <PlanPanel />}
{layout !== 'mobile' && <DragAnnouncer />}
</div>
</PlanDragProvider>
)
} }
+223
View File
@@ -0,0 +1,223 @@
/* Desktop's right panel (mock 2d) and tablet's Quest-log strip (mock 2c). */
.panel {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16px;
padding: 32px 22px;
border-left: 1px solid var(--color-divider);
}
.hint {
margin: 0;
font-family: var(--font-body);
font-size: 13px;
color: var(--color-neutral-800);
}
.rows {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: 22px;
background: var(--color-surface);
text-align: left;
}
/* The 48 px washed photo; Photo applies the washed filter. The placeholder's
"dish photo" caption does not fit, so only its stripes show. */
.rowPhoto {
width: 48px;
height: 48px;
font-size: 0;
}
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
font-family: var(--font-body);
}
.rowName {
font-size: 14px;
font-weight: 700;
overflow-wrap: anywhere;
}
.rowMeta {
font-size: 12px;
color: var(--color-neutral-700);
}
/* Two columns of three 4 px dots. */
.grip {
flex: none;
display: grid;
grid-template-columns: 4px 4px;
gap: 3px;
}
.dot {
width: 4px;
height: 4px;
border-radius: var(--radius-pill);
background: var(--color-neutral-500);
}
.more {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
text-decoration: none;
}
.more:hover {
text-decoration: underline;
}
.loading {
display: flex;
flex-direction: column;
gap: 12px;
}
.skeleton {
height: 68px;
border-radius: 22px;
}
.empty {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
/* Tablet: the Quest log as chips on a surface card under the grid. */
.strip {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 18px;
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.stripHeader {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.stripTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 18px;
font-weight: 400;
}
.stripHint {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
color: var(--color-neutral-700);
}
.chips {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 14px 5px 5px;
border-radius: var(--radius-pill);
background: var(--color-bg);
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
}
/* As the row's photo, at 28 px. */
.chipPhoto {
width: 28px;
height: 28px;
font-size: 0;
}
/* A row or a chip is a button dragged onto a day: by a pointer (no
scrolling or text selection under it) or with the keyboard. Its 2 px
border is clear until it is the ghost, inside its padding. */
.draggable {
margin: 0;
border: 2px solid transparent;
font: inherit;
color: var(--color-text);
cursor: grab;
touch-action: none;
user-select: none;
}
.row.draggable {
padding: 8px;
}
.chip.draggable {
padding: 3px 12px 3px 3px;
}
/* A dinner that cannot be picked up now: no grid to drop on, a swap
pending, or already this week. */
.draggable[aria-disabled='true'] {
cursor: default;
}
/* The row or chip being dragged: a dashed ghost at 55% opacity. */
.ghost {
border-style: dashed;
border-color: var(--color-neutral-500);
background: transparent;
opacity: 0.55;
}
/* The dragged copy floating below right of the pointer: lifted with the
large shadow and tilted -3deg. It lets the pointer through to the day
under it. */
.floating {
position: fixed;
z-index: 10;
box-sizing: border-box;
margin: 0;
box-shadow: var(--shadow-lg);
transform: rotate(-3deg);
pointer-events: none;
cursor: grabbing;
}
+210
View File
@@ -0,0 +1,210 @@
import { Compass } from 'lucide-react'
import { useId, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Link } from 'react-router'
import { useRegulars } from '../dinners/api.ts'
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { useDraggable, type Float } from './drag.tsx'
import { emptyLists, planDinner, sourceNames, type Source } from './SwapSheet.tsx'
import styles from './PlanPanel.module.css'
// DRAG_HINT says how a dinner is swapped in from the panel or the strip.
const DRAG_HINT = 'Drag a dinner onto a day to swap it in.'
// KEYS_HINT says how a dinner is swapped in with the keyboard.
const KEYS_HINT = 'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.'
type List = ReturnType<typeof useQuestLog>
// ListBody is a list of the panel or the strip once it has loaded, or why it
// has not: its error with Retry, skeletons while loading, or that it is
// empty.
function ListBody({ source, list, children }: { source: Source; list: List; children: (dinners: QuestLogDinner[]) => ReactNode }) {
if (list.isError) {
return (
<ErrorBanner onRetry={() => void list.refetch()}>
Couldn't load your {sourceNames[source]}. {list.error.message}
</ErrorBanner>
)
}
if (list.isPending) {
return (
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.loading}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className={styles.skeleton} />
))}
</div>
)
}
if (list.data.length === 0) return <p className={styles.empty}>{emptyLists[source]}</p>
return children(list.data)
}
// Grip is the 6-dot drag handle of a panel row.
function Grip() {
return (
<span className={styles.grip} aria-hidden="true">
{Array.from({ length: 6 }, (_, i) => (
<span key={i} className={styles.dot} />
))}
</span>
)
}
// Draggable is a dinner of the panel or the strip, dragged onto a day to
// swap it in: a button whose content is the row or the chip. While it is
// dragged it is a dashed ghost, and with a pointer a copy of it floats
// below right of the pointer, lifted and tilted.
function Draggable({
dinner,
fromQuestLog,
className,
keysHintId,
children,
}: {
dinner: QuestLogDinner
fromQuestLog: boolean
className: string | undefined
keysHintId: string
children: ReactNode
}) {
const { drag, float, props } = useDraggable({ dinner: planDinner(dinner), fromQuestLog })
return (
<li>
<button
type="button"
className={[className, styles.draggable, drag !== null && styles.ghost].filter(Boolean).join(' ')}
aria-describedby={keysHintId}
{...props}
>
{children}
</button>
{float !== null && <Floating float={float} className={className}>{children}</Floating>}
</li>
)
}
// Floating is the dragged copy of a row or a chip, drawn over the page.
function Floating({ float, className, children }: { float: Float; className: string | undefined; children: ReactNode }) {
return createPortal(
<div
aria-hidden="true"
data-testid="drag-float"
className={[className, styles.floating].filter(Boolean).join(' ')}
style={{ left: float.left, top: float.top, width: float.width }}
>
{children}
</div>,
document.body,
)
}
// KeysHint is the keyboard's way to swap a dinner in, describing each
// draggable.
function KeysHint({ id }: { id: string }) {
return (
<p id={id} hidden>
{KEYS_HINT}
</p>
)
}
// PlanPanel is desktop's right panel (mock 2d): the Quest log and the
// Regulars, segmented "Quest log · N | Regulars · N", each dinner with its
// 48 px photo, name, difficulty and grip, and a link to swipe more Side
// quests.
export function PlanPanel() {
const questLog = useQuestLog()
const regulars = useRegulars()
const [source, setSource] = useState<Source>('quest_log')
const lists = { quest_log: questLog, regulars }
const keysHintId = useId()
const label = (s: Source) => {
const dinners = lists[s].data
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
}
return (
<aside aria-label="Quest log and Regulars" className={styles.panel}>
<Segmented
label="Dinners to swap in"
options={[
{ value: 'quest_log', label: label('quest_log') },
{ value: 'regulars', label: label('regulars') },
]}
value={source}
onValueChange={setSource}
/>
<p className={styles.hint}>{DRAG_HINT}</p>
<KeysHint id={keysHintId} />
<ListBody source={source} list={lists[source]}>
{(dinners) => (
<ul aria-label={sourceNames[source]} className={styles.rows}>
{dinners.map((dinner) => (
<Draggable
key={dinner.id}
dinner={dinner}
fromQuestLog={source === 'quest_log'}
className={styles.row}
keysHintId={keysHintId}
>
{/* The row's name already says what the photo shows. */}
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="48px" className={styles.rowPhoto} />
</span>
<span className={styles.rowText}>
<span className={styles.rowName}>{dinner.name}</span>
{dinner.difficulty !== null && <span className={styles.rowMeta}>{DIFFICULTY_LABEL[dinner.difficulty]}</span>}
</span>
<Grip />
</Draggable>
))}
</ul>
)}
</ListBody>
<Link to="/discover" className={styles.more}>
<Icon icon={Compass} size="inline" />
Swipe more side quests
</Link>
</aside>
)
}
// QuestLogStrip is tablet's Quest log under the week's grid (mock 2c): each
// dinner a chip with its 28 px photo and name, dragged as the panel's rows
// are.
export function QuestLogStrip() {
const questLog = useQuestLog()
const keysHintId = useId()
const title = questLog.data === undefined ? sourceNames.quest_log : `${sourceNames.quest_log} · ${questLog.data.length}`
return (
<section aria-label={sourceNames.quest_log} className={styles.strip}>
<div className={styles.stripHeader}>
<h2 className={styles.stripTitle}>{title}</h2>
<p className={styles.stripHint}>{DRAG_HINT}</p>
</div>
<KeysHint id={keysHintId} />
<ListBody source="quest_log" list={questLog}>
{(dinners) => (
<ul aria-label={`${sourceNames.quest_log} dinners`} className={styles.chips}>
{dinners.map((dinner) => (
<Draggable key={dinner.id} dinner={dinner} fromQuestLog className={styles.chip} keysHintId={keysHintId}>
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="28px" className={styles.chipPhoto} />
</span>
{dinner.name}
</Draggable>
))}
</ul>
)}
</ListBody>
</section>
)
}
+57
View File
@@ -0,0 +1,57 @@
/* The mobile swap sheet: the Quest log and Regulars of desktop's plan panel
(mock 2d) as a bottom sheet. */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.replaces {
margin: 2px 0 0;
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
color: var(--color-neutral-700);
}
.rows {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
/* The 48 px washed photo; Photo applies the washed filter. */
.photo {
width: 48px;
height: 48px;
}
/* A dinner already this week: shown, muted, and not a button. */
.already {
color: var(--color-neutral-600);
}
.skeletonRow {
height: 72px;
border-radius: var(--radius-md);
}
.empty {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
+129
View File
@@ -0,0 +1,129 @@
import { useId, useState } from 'react'
import { useRegulars } from '../dinners/api.ts'
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
import { details } from '../discover/QuestLogSheet.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { ListRow } from '../ui/ListRow.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import type { PlanDay, PlanDinner, Swap } from './api.ts'
import { weekdayName } from './week.ts'
import styles from './SwapSheet.module.css'
// Source is a list a dinner is swapped in from.
export type Source = 'quest_log' | 'regulars'
export const sourceNames: Record<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
export const emptyLists: Record<Source, string> = {
quest_log: 'Your Quest log is empty — swipe right on a Side quest to add one.',
regulars: 'You have no Regulars yet.',
}
// planDinner is a listed dinner as it is planned on a day.
export function planDinner(dinner: QuestLogDinner): PlanDinner {
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
}
export type SwapSheetProps = {
// The day being swapped.
day: PlanDay
// The ids of the dinners already on the week's plan.
planned: ReadonlySet<string>
onSwap: (swap: Swap) => void
onClose: () => void
}
// SwapSheet is the mobile swap sheet: the Quest log and the Regulars, as
// desktop's plan panel lists them, segmented "Quest log · N | Regulars · N",
// each dinner with its 48 px photo, name, difficulty and minutes. Choosing a
// dinner swaps it into the day; a dinner already this week can't be chosen.
export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
const titleId = useId()
const questLog = useQuestLog()
const regulars = useRegulars()
const [source, setSource] = useState<Source>('quest_log')
const lists = { quest_log: questLog, regulars }
const list = lists[source]
const label = (s: Source) => {
const dinners = lists[s].data
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
}
let body
if (list.isError) {
body = (
<ErrorBanner onRetry={() => void list.refetch()}>
Couldn't load your {sourceNames[source]}. {list.error.message}
</ErrorBanner>
)
} else if (list.isPending) {
body = (
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.rows}>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className={styles.skeletonRow} />
))}
</div>
)
} else if (list.data.length === 0) {
body = <p className={styles.empty}>{emptyLists[source]}</p>
} else {
body = (
<ul aria-label={sourceNames[source]} className={styles.rows}>
{list.data.map((dinner: QuestLogDinner) => {
const already = planned.has(dinner.id)
return (
<li key={dinner.id}>
<ListRow
className={already ? styles.already : undefined}
leading={
// The row's name already says what the photo shows.
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-md)" sizes="48px" className={styles.photo} />
</span>
}
title={dinner.name}
meta={details(dinner)}
// A dinner already this week is no button: it can't be chosen.
onClick={
already
? undefined
: () =>
onSwap({ date: day.date, dinner: planDinner(dinner), fromQuestLog: source === 'quest_log' })
}
trailing={already && <Tag>Already this week</Tag>}
/>
</li>
)
})}
</ul>
)
}
return (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<div>
<h2 id={titleId} className={styles.title}>
Swap {weekdayName(day.date)}
</h2>
<p className={styles.replaces}>Replaces {day.dinner.name}</p>
</div>
<Segmented
label="Swap in from"
options={[
{ value: 'quest_log', label: label('quest_log') },
{ value: 'regulars', label: label('regulars') },
]}
value={source}
onValueChange={setSource}
/>
{body}
</div>
</Sheet>
)
}
+205
View File
@@ -0,0 +1,205 @@
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { planKey, usePlan, useSwapDay, type Plan } from './api.ts'
const WEEK = '2026-10-05'
const PLAN = `/api/v1/plans/${WEEK}`
const MONDAY = `${PLAN}/days/2026-10-05`
const FRIDAY = `${PLAN}/days/2026-10-09`
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
// planBody is the plan of the week of Oct 5, dinners d0 to d6, with days
// changed as given by index.
function planBody(changes: Record<number, { id: string; name: string; is_new?: boolean; start_by?: string | null }> = {}) {
return {
id: 'plan-1',
week_start: WEEK,
status: 'draft',
ai_note: null,
calendar_synced_at: null as string | null,
days: DAYS.map((date, i) => {
const change = changes[i]
return {
date,
dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}`, photo: null, minutes: null, method: null as string | null, origin: 'regular' },
is_new: change?.is_new ?? false,
start_by: change?.start_by ?? null,
}
}),
}
}
// deferred is a reply the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const reply = new Promise<Response>((resolve) => (send = resolve))
return { reply, send }
}
// stubServer answers "METHOD path" requests from responders, with /me and
// the Quest log; any other request fails the test.
function stubServer(responders: Record<string, () => Response | Promise<Response>>) {
const all: Record<string, () => Response | Promise<Response>> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
...responders,
}
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond())
}),
)
}
// renderSwaps renders the week's plan and swaps over a query client.
async function renderSwaps() {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient: QueryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(() => ({ plan: usePlan(WEEK), swap: useSwapDay(WEEK) }), { wrapper })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
return { ...hook, queryClient, plan: () => queryClient.getQueryData<Plan>(planKey(WEEK))! }
}
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo', photo_id: undefined, minutes: null, method: null }, fromQuestLog: true }
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos', photo_id: undefined, minutes: null, method: null }, fromQuestLog: false }
afterEach(() => {
vi.unstubAllGlobals()
})
// renderPlan renders the week's plan read over a query client.
async function renderPlan() {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(() => usePlan(WEEK), { wrapper })
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
return hook.result.current
}
describe('usePlan', () => {
it("reads each dinner's method and when the plan was pushed to Calendar", async () => {
const body = planBody()
body.days[2]!.dinner.method = 'crockpot'
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, { ...body, status: 'synced', calendar_synced_at: '2026-10-07T15:58:00Z' }) })
const plan = await renderPlan()
expect(plan.data?.calendar_synced_at).toEqual(new Date('2026-10-07T15:58:00Z'))
expect(plan.data?.days.map((day) => day.dinner.method)).toEqual([null, null, 'crockpot', null, null, null, null])
})
it.each([
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
])('refuses a plan with %s', async (_, change, said) => {
const body = planBody()
change(body)
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, body) })
const plan = await renderPlan()
expect(plan.error?.message).toContain(said)
})
})
describe('useSwapDay', () => {
// Friday's swap is sent first, then Monday's; the server answers Monday's
// with both swapped, Friday's with only Friday.
const fridayPlan = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
const bothPlan = planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
it.each([
['the later swap answered first', ['monday', 'friday'] as const],
['the earlier swap answered first', ['friday', 'monday'] as const],
])('keeps the plan of the later of two swaps, %s', async (_, order) => {
const replies = { friday: deferred(), monday: deferred() }
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => replies.friday.reply,
[`PUT ${MONDAY}`]: () => replies.monday.reply,
})
const { result, plan, queryClient } = await renderSwaps()
act(() => result.current.swap.mutate(ORZO))
act(() => result.current.swap.mutate(FISH))
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
// Whichever reply settles, Monday stays swapped: Friday's plan, the
// earlier one, never lands over it.
for (const [settled, day] of order.entries()) {
await act(async () => replies[day].send(jsonResponse(200, day === 'friday' ? fridayPlan : bothPlan)))
await waitFor(() => expect(queryClient.isMutating()).toBe(1 - settled))
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
}
expect(plan().days.map((day) => day.dinner.name)).toEqual(bothPlan.days.map((day) => day.dinner.name))
})
it("does not take an earlier swap's failure for the week's", async () => {
const friday = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => friday.reply,
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
})
const { result, plan } = await renderSwaps()
act(() => result.current.swap.mutate(ORZO))
act(() => result.current.swap.mutate(FISH))
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
await waitFor(() => expect(result.current.swap.pending).toBe(false))
expect(result.current.swap.failed).toBeNull()
})
it('leaves a day a later plan read landed when the swap of it fails', async () => {
const friday = deferred()
// The read finds Friday already lemon orzo, starting by 2 pm.
let read = planBody()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, read),
[`PUT ${FRIDAY}`]: () => friday.reply,
})
const { result, plan, queryClient } = await renderSwaps()
act(() => result.current.swap.mutate(ORZO))
await waitFor(() => expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }))
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true, start_by: '2026-10-09T14:00:00-04:00' } })
await act(() => result.current.plan.refetch())
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: '2026-10-09T14:00:00-04:00' })
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
})
it('refuses to dismiss a pending swap', async () => {
const friday = deferred()
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
const { result } = await renderSwaps()
act(() => result.current.swap.mutate(ORZO))
await waitFor(() => expect(result.current.swap.pending).toBe(true))
expect(() => result.current.swap.dismiss()).toThrow('The week of 2026-10-05 has a swap pending, which cannot be dismissed.')
await act(async () => friday.send(jsonResponse(200, planBody())))
})
})
+150 -6
View File
@@ -1,7 +1,8 @@
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { ApiError, useApi, type ApiRead } from '../api/index.ts'
import { isRecord } from '../api/errors.ts' import { isRecord } from '../api/errors.ts'
import { oneOf } from '../dinners/api.ts' import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
export const PLANS_PATH = '/api/v1/plans' export const PLANS_PATH = '/api/v1/plans'
@@ -21,24 +22,31 @@ export const PLAN_STATUSES = ['draft', 'synced', 'changed'] as const
// synced plan makes it changed. // synced plan makes it changed.
export type PlanStatus = (typeof PLAN_STATUSES)[number] export type PlanStatus = (typeof PLAN_STATUSES)[number]
// PlanDinner is a planned dinner: photo_id is its stored photo's id, from
// photo, minutes its minutes from prep to table and method how it is
// cooked, each when known.
export type PlanDinner = { id: string; name: string; photo_id: string | undefined; minutes: number | null; method: Method | null }
// PlanDay is one day of a plan: its household-local date, the dinner // PlanDay is one day of a plan: its household-local date, the dinner
// planned, whether that dinner is a Quest-log pick, and, for a crock-pot // planned, whether that dinner is a Quest-log pick, and, for a crock-pot
// dinner of known minutes, the instant it must start by. // dinner of known minutes, the instant it must start by.
export type PlanDay = { export type PlanDay = {
date: string date: string
dinner: { id: string; name: string } dinner: PlanDinner
is_new: boolean is_new: boolean
start_by: string | null start_by: string | null
} }
// Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to // Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to
// Sunday, and the AI's note on the draft, if any. cached says the service // Sunday, the AI's note on the draft, if any, and when it was last pushed to
// worker answered from its cache: the server may have changed the plan since. // Google Calendar, if ever. cached says the service worker answered from its
// cache: the server may have changed the plan since.
export type Plan = { export type Plan = {
id: string id: string
week_start: string week_start: string
status: PlanStatus status: PlanStatus
ai_note: string | null ai_note: string | null
calendar_synced_at: Date | null
days: PlanDay[] days: PlanDay[]
cached: boolean cached: boolean
} }
@@ -56,12 +64,30 @@ function parseDay(where: string, body: unknown): PlanDay {
!isRecord(body.dinner) || !isRecord(body.dinner) ||
typeof body.dinner.id !== 'string' || typeof body.dinner.id !== 'string' ||
typeof body.dinner.name !== 'string' || typeof body.dinner.name !== 'string' ||
(body.dinner.minutes !== null && !Number.isInteger(body.dinner.minutes)) ||
typeof body.is_new !== 'boolean' || typeof body.is_new !== 'boolean' ||
(body.start_by !== null && typeof body.start_by !== 'string') (body.start_by !== null && typeof body.start_by !== 'string')
) { ) {
throw new Error(`${where}: response has a day that is not a plan day`) throw new Error(`${where}: response has a day that is not a plan day`)
} }
return { date: body.date, dinner: { id: body.dinner.id, name: body.dinner.name }, is_new: body.is_new, start_by: body.start_by } const dinner: PlanDinner = {
id: body.dinner.id,
name: body.dinner.name,
photo_id: photoId(where, body.dinner.photo),
minutes: body.dinner.minutes as number | null,
method: body.dinner.method === null ? null : oneOf(where, 'method', METHODS, body.dinner.method),
}
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
}
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
function syncedAt(where: string, value: unknown): Date | null {
if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
}
return at
} }
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan { function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
@@ -80,6 +106,7 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool
week_start: weekStart, week_start: weekStart,
status: oneOf(where, 'status', PLAN_STATUSES, body.status), status: oneOf(where, 'status', PLAN_STATUSES, body.status),
ai_note: body.ai_note, ai_note: body.ai_note,
calendar_synced_at: syncedAt(where, body.calendar_synced_at),
days: body.days.map((day) => parseDay(where, day)), days: body.days.map((day) => parseDay(where, day)),
cached, cached,
} }
@@ -130,3 +157,120 @@ export function useStartDraft() {
}, },
}) })
} }
// Swap is one day's swap: the day, and the dinner chosen for it with whether
// it was chosen from the Quest log.
export type Swap = { date: string; dinner: PlanDinner; fromQuestLog: boolean }
// sameDay says whether two plan days are the same in every field.
function sameDay(a: PlanDay, b: PlanDay): boolean {
return (
a.date === b.date &&
a.dinner.id === b.dinner.id &&
a.dinner.name === b.dinner.name &&
a.dinner.photo_id === b.dinner.photo_id &&
a.dinner.minutes === b.dinner.minutes &&
a.dinner.method === b.dinner.method &&
a.is_new === b.is_new &&
a.start_by === b.start_by
)
}
// swapKey keys the swaps of the week starting on weekStart in the mutation
// cache, where they outlive the Draft board that sent them.
function swapKey(weekStart: string) {
return ['plans', weekStart, 'swap'] as const
}
// SwapDay is the swaps of one week, as the mutation cache holds them:
// whether one is pending, and the failure of the latest, if it failed.
export type SwapDay = {
pending: boolean
failed: { swap: Swap; error: Error } | null
mutate: (chosen: Swap) => void
dismiss: () => void
}
// useSwapDay swaps the dinner of one day of the week starting on weekStart
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
// once, NEW when it came from the Quest log, with no start-by time until the
// server answers with the plan as swapped, which then replaces it. A refused
// swap puts the day back as it was, unless it has changed again meanwhile.
// However the swap ends, the Quest log is read again: the server leaves a
// dinner on the plan out of it.
//
// The week's swaps live in the mutation cache, not in the component, so
// leaving the week and coming back keeps them: pending and failed are read
// from there. Only the latest swap of the week speaks for it: the plan an
// earlier one is answered with is not written over a later one, and its
// failure is not the week's. dismiss leaves settled swaps behind; it is
// refused while one is pending, so a later swap is always in the cache for
// an earlier, pending one to see.
export function useSwapDay(weekStart: string): SwapDay {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const key = planKey(weekStart)
const mutationKey = swapKey(weekStart)
const mutations = queryClient.getMutationCache()
// The week's swaps, in the order sent.
const swaps = useMutationState({
filters: { mutationKey },
select: (mutation) => ({ status: mutation.state.status, swap: mutation.state.variables as Swap, error: mutation.state.error }),
})
const latest = swaps.at(-1)
function mutate(chosen: Swap) {
// isLatest says whether no swap of the week was sent after this one.
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
mutationKey,
// Kept until dismissed: a later swap must stay in the cache while an
// earlier one is pending.
gcTime: Infinity,
mutationFn: async ({ date, dinner }) => {
const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}`
const body = await apiFetch<unknown>(path, { method: 'PUT', body: { dinner_id: dinner.id } })
return parsePlan(`PUT ${path}`, weekStart, body, false)
},
onMutate: async ({ date, dinner, fromQuestLog }) => {
await queryClient.cancelQueries({ queryKey: key })
const plan = queryClient.getQueryData<Plan | null>(key)
const previous = plan?.days.find((day) => day.date === date)
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
return { previous, swapped }
},
onSuccess: (plan) => {
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
},
// The day goes back only while it is still, field for field, the day
// this swap wrote: a later swap or plan read that replaced it is left
// alone.
onError: (_error, _swap, context) => {
if (context === undefined) return
queryClient.setQueryData<Plan | null>(key, (plan) =>
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
})
// A failure is the mutation's state, which failed reports.
mutation.execute(chosen).catch(() => undefined)
}
function dismiss() {
const week = mutations.findAll({ mutationKey })
if (week.some((mutation) => mutation.state.status === 'pending')) {
throw new Error(`The week of ${weekStart} has a swap pending, which cannot be dismissed.`)
}
for (const mutation of week) mutations.remove(mutation)
}
return {
pending: swaps.some((swap) => swap.status === 'pending'),
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
mutate,
dismiss,
}
}
+12
View File
@@ -0,0 +1,12 @@
/* The drag's live region: read out, not shown. */
.announcer {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
+353
View File
@@ -0,0 +1,353 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from 'react'
import type { PlanDay, PlanDinner, Swap } from './api.ts'
import { weekdayName } from './week.ts'
import styles from './drag.module.css'
// Pickup is a dinner picked up from the plan panel or the Quest-log strip,
// and whether it is a Quest-log dinner.
export type Pickup = { dinner: PlanDinner; fromQuestLog: boolean }
// Board is the week's grid as a drag drops onto it: its days, whether a
// swap of the week is pending, and how a dinner dropped on a day is swapped
// in.
export type Board = { days: PlanDay[]; pending: boolean; drop: (swap: Swap) => void }
// Drag is the dinner being dragged, by pointer or keyboard, and the date of
// the day it would replace, if any.
export type Drag = { pickup: Pickup; keyboard: boolean; date: string | null }
type PlanDrag = {
drag: Drag | null
board: Board | null
setBoard: (board: Board | null) => void
message: string
// start picks a dinner up, unless it cannot be dropped: there is no grid,
// a swap of the week is pending, or the dinner is already this week. It
// says whether it did.
start: (pickup: Pickup, keyboard: boolean) => boolean
// over moves the drag onto the day of date, or off every day (null).
over: (date: string | null) => void
// step moves a keyboard drag to the previous (-1) or next (1) day.
step: (by: -1 | 1) => void
// end drops the dinner on the day of date, or cancels the drag when date
// is null. It says whether the dinner was dropped.
end: (date: string | null) => boolean
cancel: () => void
}
const PlanDragContext = createContext<PlanDrag | null>(null)
function usePlanDrag(): PlanDrag {
const context = useContext(PlanDragContext)
if (context === null) throw new Error('usePlanDrag must be used inside PlanDragProvider')
return context
}
// replaces says which dinner dropping on day replaces: "Friday: replaces
// Pizza night."
function replaces(day: PlanDay): string {
return `${weekdayName(day.date)}: replaces ${day.dinner.name}.`
}
// PlanDragProvider holds the drag of a dinner from the plan panel or the
// Quest-log strip onto a day of the week's grid, which the grid registers as
// the Board. Each step of a drag is announced to screen readers.
export function PlanDragProvider({ children }: { children: ReactNode }) {
const [board, setBoard] = useState<Board | null>(null)
const [drag, setDrag] = useState<Drag | null>(null)
const [message, setMessage] = useState('')
// The drag as last set, read by handlers that run before a render: a blur
// the drop's focus move causes must not cancel the drag it just ended.
const current = useRef<Drag | null>(null)
const value = useMemo((): PlanDrag => {
function set(next: Drag | null) {
current.current = next
setDrag(next)
}
function dayOf(date: string | null): PlanDay | undefined {
return board?.days.find((day) => day.date === date)
}
function cancel() {
const now = current.current
if (now === null) return
set(null)
setMessage(`Cancelled. ${now.pickup.dinner.name} was not swapped in.`)
}
return {
drag,
board,
setBoard,
message,
start(pickup, keyboard) {
const { name } = pickup.dinner
if (board === null) {
setMessage(`There is no plan this week to swap ${name} into.`)
return false
}
if (board.pending) {
setMessage(`A swap is still being saved. Pick ${name} up once it is done.`)
return false
}
if (board.days.some((day) => day.dinner.id === pickup.dinner.id)) {
setMessage(`${name} is already this week.`)
return false
}
// A keyboard drag starts on Monday; a pointer drag on no day.
const first = board.days[0]!
set({ pickup, keyboard, date: keyboard ? first.date : null })
setMessage(
keyboard
? `Picked up ${name}. ${replaces(first)} Use the arrow keys to choose a day, Enter to drop, Escape to cancel.`
: `Picked up ${name}.`,
)
return true
},
over(date) {
const now = current.current
if (now === null || now.date === date) return
set({ ...now, date })
},
step(by) {
const now = current.current
if (now === null || board === null) return
const at = board.days.findIndex((day) => day.date === now.date)
const next = board.days[Math.min(Math.max(at + by, 0), board.days.length - 1)]!
set({ ...now, date: next.date })
setMessage(replaces(next))
},
end(date) {
const now = current.current
if (now === null) return false
const day = dayOf(date)
if (day === undefined || board === null) {
cancel()
return false
}
set(null)
if (board.pending) {
setMessage(`A swap is still being saved. ${now.pickup.dinner.name} was not swapped in.`)
return false
}
board.drop({ date: day.date, dinner: now.pickup.dinner, fromQuestLog: now.pickup.fromQuestLog })
setMessage(`Dropped ${now.pickup.dinner.name} on ${weekdayName(day.date)}, replacing ${day.dinner.name}.`)
return true
},
cancel,
}
}, [board, drag, message])
return <PlanDragContext.Provider value={value}>{children}</PlanDragContext.Provider>
}
// DragAnnouncer is the polite live region a drag's steps are announced in.
export function DragAnnouncer() {
const { message } = usePlanDrag()
return (
<p aria-live="polite" aria-atomic="true" data-testid="drag-announcer" className={styles.announcer}>
{message}
</p>
)
}
// DropBoard registers the week's grid as the Board while it is shown.
export function DropBoard({ days, pending, drop }: Board) {
const { setBoard } = usePlanDrag()
// drop is a new function on every render: the Board calls the latest.
const latestDrop = useRef(drop)
useLayoutEffect(() => {
latestDrop.current = drop
})
useLayoutEffect(() => {
setBoard({ days, pending, drop: (swap) => latestDrop.current(swap) })
return () => setBoard(null)
}, [setBoard, days, pending])
return null
}
// useDropTarget is the drag over the day of date, if it is over that day.
export function useDropTarget(date: string): Drag | null {
const { drag } = usePlanDrag()
return drag !== null && drag.date === date ? drag : null
}
// DROP_DATE is the attribute naming the date of a day a dinner drops on.
export const DROP_DATE = 'data-drop-date'
// dateAt is the date of the day under the point (x, y), or null when it is
// over no day.
function dateAt(x: number, y: number): string | null {
return document.elementFromPoint(x, y)?.closest(`[${DROP_DATE}]`)?.getAttribute(DROP_DATE) ?? null
}
// A press becomes a drag once the pointer has moved this far, so a tap is
// not one.
const DRAG_THRESHOLD = 4
// The dragged copy floats this far below and to the right of the pointer,
// as a drag image does, so it stays clear of the "Drop to swap" text
// centred in the day the pointer is over. Where it would leave the
// viewport it flips to the left of the pointer, or above it.
const FLOAT_OFFSET = 48
// Press is a pointer held on a draggable: its id, where it went down, the
// draggable's size, and whether it has become a drag.
type Press = { pointerId: number; x: number; y: number; width: number; height: number; dragging: boolean }
// Float is where the dragged copy of a draggable is drawn, and how wide.
export type Float = { left: number; top: number; width: number }
// floatAt is where the dragged copy of the held draggable is drawn for the
// pointer at (x, y): offset below right of it, flipped where that would
// leave the viewport.
function floatAt(held: Press, x: number, y: number): Float {
const right = x + FLOAT_OFFSET
const below = y + FLOAT_OFFSET
return {
left: right + held.width > window.innerWidth ? x - FLOAT_OFFSET - held.width : right,
top: below + held.height > window.innerHeight ? y - FLOAT_OFFSET - held.height : below,
width: held.width,
}
}
// useDraggable makes a panel row or a strip chip a draggable dinner. With a
// pointer it is pressed, captured and moved past DRAG_THRESHOLD to pick it
// up, its copy floats with the pointer, and letting go over a day drops it
// there, anywhere else cancels. With the keyboard Enter or Space picks it
// up onto Monday, the arrow keys choose the day, Enter drops it and
// Escape, or leaving it, cancels; a drop moves focus to the day's swap
// button, as a Quest-log dinner leaves the Quest log once planned.
export function useDraggable(pickup: Pickup) {
const plan = usePlanDrag()
const [float, setFloat] = useState<Float | null>(null)
const press = useRef<Press | null>(null)
const drag = plan.drag !== null && plan.drag.pickup.dinner.id === pickup.dinner.id ? plan.drag : null
const disabled = plan.drag === null && (plan.board === null || plan.board.pending || plan.board.days.some((day) => day.dinner.id === pickup.dinner.id))
// A draggable that goes mid-drag, as on crossing to mobile, cancels it;
// once the drag has ended, cancel does nothing.
const { cancel } = plan
const dragging = drag !== null
const latestCancel = useRef(cancel)
useLayoutEffect(() => {
latestCancel.current = cancel
})
useEffect(() => {
if (!dragging) return
return () => latestCancel.current()
}, [dragging])
function release() {
press.current = null
setFloat(null)
}
function onPointerDown(event: PointerEvent<HTMLElement>) {
if (press.current !== null || plan.drag !== null || !event.isPrimary || event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect()
event.currentTarget.setPointerCapture(event.pointerId)
press.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
width: rect.width,
height: rect.height,
dragging: false,
}
}
function onPointerMove(event: PointerEvent<HTMLElement>) {
const held = press.current
if (held === null || held.pointerId !== event.pointerId) return
if (!held.dragging) {
if (Math.hypot(event.clientX - held.x, event.clientY - held.y) < DRAG_THRESHOLD) return
if (!plan.start(pickup, false)) {
press.current = null
return
}
held.dragging = true
}
setFloat(floatAt(held, event.clientX, event.clientY))
plan.over(dateAt(event.clientX, event.clientY))
}
function onPointerUp(event: PointerEvent<HTMLElement>) {
const held = press.current
if (held === null || held.pointerId !== event.pointerId) return
release()
if (held.dragging) plan.end(dateAt(event.clientX, event.clientY))
}
// A cancel, or losing the capture before the release, cancels the drag.
// After a release the press is already over, so this ends only a drag
// whose capture was lost before it.
function onPointerCancel(event: PointerEvent<HTMLElement>) {
const held = press.current
if (held === null || held.pointerId !== event.pointerId) return
release()
if (held.dragging) plan.cancel()
}
function onKeyDown(event: KeyboardEvent<HTMLElement>) {
const button = event.currentTarget
if (drag === null) {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (plan.drag === null) plan.start(pickup, true)
return
}
if (event.key === 'Escape') {
event.preventDefault()
release()
plan.cancel()
return
}
if (!drag.keyboard) return
// Space picked it up and does not drop it; it does not scroll the page.
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') plan.step(-1)
else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') plan.step(1)
else if (event.key === 'Enter') drop(button.ownerDocument, drag.date)
else if (event.key !== ' ') return
event.preventDefault()
}
// drop ends a keyboard drag on the day of date and, once dropped, moves
// focus to that day's swap button.
function drop(document: Document, date: string | null) {
if (!plan.end(date)) return
const swapButton = document.querySelector<HTMLElement>(`[${DROP_DATE}="${date}"] button`)
if (swapButton === null) throw new Error(`The day ${date} dropped on has no swap button to focus.`)
swapButton.focus()
}
// Leaving a keyboard drag cancels it.
function onBlur() {
if (drag !== null && drag.keyboard) plan.cancel()
}
return {
drag,
float,
props: {
'aria-disabled': disabled || undefined,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onLostPointerCapture: onPointerCancel,
onKeyDown,
onBlur,
},
}
}
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel, weekStart } from './week.ts' import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York. // SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' } const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
@@ -31,6 +31,17 @@ describe('week helpers', () => {
expect(weekdayKicker('2026-10-11')).toBe('SUN') expect(weekdayKicker('2026-10-11')).toBe('SUN')
}) })
it('names the day of a date in full', () => {
expect(weekdayName('2026-10-09')).toBe('Friday')
expect(weekdayName('2026-10-11')).toBe('Sunday')
})
it('reads the day of the month of a date', () => {
expect(dayOfMonth('2026-10-05')).toBe(5)
expect(dayOfMonth('2026-10-31')).toBe(31)
expect(() => dayOfMonth('2026-10-32')).toThrow('not a real date')
})
it('refuses a week start that is not a Monday, and a date that is not real', () => { it('refuses a week start that is not a Monday, and a date that is not real', () => {
expect(() => weekLabel('2026-10-06')).toThrow('week start 2026-10-06 is not a Monday') expect(() => weekLabel('2026-10-06')).toThrow('week start 2026-10-06 is not a Monday')
expect(() => addDays('2026-02-30', 1)).toThrow('not a real date') expect(() => addDays('2026-02-30', 1)).toThrow('not a real date')
+12
View File
@@ -40,6 +40,18 @@ export function weekdayKicker(date: string): string {
return WEEKDAYS[midnightUTC(date).getUTCDay()]! return WEEKDAYS[midnightUTC(date).getUTCDay()]!
} }
// dayOfMonth is date's day of the month: 1 to 31.
export function dayOfMonth(date: string): number {
return midnightUTC(date).getUTCDate()
}
const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' })
// weekdayName is date's day in full: "Monday" to "Sunday".
export function weekdayName(date: string): string {
return weekday.format(midnightUTC(date))
}
// monday reads start as midnight UTC, or throws when it is not a Monday. // monday reads start as midnight UTC, or throws when it is not a Monday.
function monday(start: string): Date { function monday(start: string): Date {
const t = midnightUTC(start) const t = midnightUTC(start)
+5
View File
@@ -19,3 +19,8 @@
background: var(--color-accent-2-200); background: var(--color-accent-2-200);
color: var(--color-accent-2-800); color: var(--color-accent-2-800);
} }
.neutral {
background: var(--color-neutral-100);
color: var(--color-neutral-800);
}
+5
View File
@@ -15,4 +15,9 @@ describe('Tag', () => {
render(<Tag tone="teal">Synced</Tag>) render(<Tag tone="teal">Synced</Tag>)
expect(screen.getByText('Synced')).toHaveClass(styles.teal!) expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
}) })
it('supports the neutral tone', () => {
render(<Tag tone="neutral">Regular</Tag>)
expect(screen.getByText('Regular')).toHaveClass(styles.neutral!)
})
}) })
+3 -2
View File
@@ -1,13 +1,14 @@
import type { ComponentPropsWithRef } from 'react' import type { ComponentPropsWithRef } from 'react'
import styles from './Tag.module.css' import styles from './Tag.module.css'
export type TagTone = 'grape' | 'teal' export type TagTone = 'grape' | 'teal' | 'neutral'
export type TagProps = ComponentPropsWithRef<'span'> & { export type TagProps = ComponentPropsWithRef<'span'> & {
tone?: TagTone tone?: TagTone
} }
// Static label such as "Draft", "Side quest" or "Synced". Not interactive. // Static label such as "Draft", "Side quest", "Synced" or "Regular". Not
// interactive.
export function Tag({ tone = 'grape', className, ...rest }: TagProps) { export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ') const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
return <span className={classes} {...rest} /> return <span className={classes} {...rest} />