Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 b3b342ef55 Revalidate the draft pool on save, refuse duplicate swaps, refresh the Quest log after a draft
CI / ci (push) Successful in 3m18s
CI / ci (pull_request) Successful in 3m19s
Feature 14 integration fix (Maestro 857e4c33-e250-46ec-b5ea-7dd4f3b719d9).

- F14-01: storeDraft re-reads the draft pool (ListDraftPool, the same
  eligibility rule draft_week was given) inside the save transaction. A
  day whose dinner was archived, moved to tried, or planned on another
  current or later week while draft_week ran fails the job with the new
  retryable reason pool_changed and stores nothing.
- F14-02: Swap refuses a dinner already on another day of the week inside
  the write transaction; the API answers 409 dinner_already_planned.
- F14-03: a finished draft invalidates the Quest log query, as the swap
  path does, so the tablet strip and desktop panel drop dinners it planned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:13:22 -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
22 changed files with 2470 additions and 149 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
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
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
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
`{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
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` schedule calls for the household-local day just past,
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,
// 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
// draft_week ran. A pool under seven dinners fails with
// ReasonNotEnoughDinners and draft_week failing with ReasonAIFailed; no
// week is made up instead.
// draft_week ran and every dinner it planned is still in the pool. A pool
// under seven dinners fails with ReasonNotEnoughDinners and draft_week
// failing with ReasonAIFailed; no week is made up instead.
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
weekStart, err := ParseWeekStart(p.WeekStart)
if err != nil {
@@ -68,7 +68,7 @@ func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult)
for _, w := range week.Warnings {
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 {
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
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
// revision before draft_week ran, or noPlan), failing with
// ReasonPlanChanged.
func (s *Service) storeDraft(ctx context.Context, weekStart store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
// ReasonPlanChanged. The pool is read again, as of thisWeek like the pool
// 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
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
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()
note := sql.NullString{String: week.Note, Valid: week.Note != ""}
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
// draft does not replace the edit.
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.
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged}
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged}
// ContextBudget is the character budget of the household context
// 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.
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
// without 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
// 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
// must be one of its days (else *InvalidDayError), and the dinner must be
// a Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError). It returns the plan as swapped.
// must be one of its days (else *InvalidDayError), the dinner must be a
// Regular or in the Quest log (else ErrDinnerNotFound or
// *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) {
dates, err := household.WeekDays(weekStart)
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) {
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()
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
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
// weekDates, in order.
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
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)
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)
}
}
// Swapped to Beef Chili, Sunday starts at 13:30 EST.
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", e.ids["Beef Chili"]); err != nil {
// Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST.
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)
}
if p, err = e.svc.Get(t.Context(), dst); err != nil {
t.Fatal(err)
}
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) {
e := newEnv(t)
e.start(t)
@@ -633,7 +704,8 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
t.Fatal(err)
}
<-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)
}
close(release)
@@ -646,7 +718,7 @@ func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
t.Fatal(err)
}
want := slices.Clone(fullWeek)
want[2] = "Sheet Pan Salmon"
want[2] = "Fish Tacos"
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)
}
@@ -822,7 +894,8 @@ func TestSwapStatusTransitions(t *testing.T) {
e.reply("", fullWeek...)
e.mustDraft(t, false)
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 {
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) }},
{"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) }},
{"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) }},
+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
// 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
// unconfirmed one runs, or planning a dinner that is neither a Regular nor
// in the Quest log, is 409.
// unconfirmed one runs, planning a dinner that is neither a Regular nor in
// 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) {
var day *plan.InvalidDayError
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())
case errors.As(err, &notPlannable):
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):
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
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-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-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)
if rec.Code != tc.status || errorCode(body) != tc.code {
+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 }
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
// it: photo, name, difficulty and minutes.
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'minutes_total'>
// it: photo, name, difficulty and minutes, with the method a swap carries
// 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.
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)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner) => {
const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner)
return { id, name, photo_id, difficulty, minutes_total }
const { id, name, photo_id, difficulty, method, minutes_total } = parseDinner(where, dinner)
return { id, name, photo_id, difficulty, method, minutes_total }
})
},
})
@@ -250,13 +251,14 @@ export function useDecide() {
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
? dinners
: // The deck does not say the minutes; the Quest log read after the
// decision does.
: // The deck does not say the method or minutes; the Quest log
// read after the decision does.
dinners.concat({
id: quest.dinner_id,
name: quest.name,
photo_id: quest.photo_id,
difficulty: quest.difficulty,
method: null,
minutes_total: null,
}),
)
+318 -10
View File
@@ -1,13 +1,40 @@
/* Plan · Draft board (mock 1f). */
/* Plan · Draft board (mock 1f) and plan manager (mocks 2c, 2d). */
.page {
box-sizing: border-box;
max-width: 440px;
}
.board {
box-sizing: border-box;
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
}
.mobilePage {
max-width: 440px;
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 {
display: flex;
align-items: flex-end;
@@ -231,9 +258,10 @@
color: var(--color-neutral-700);
}
/* "Looks good — push to Calendar", unavailable until Feature 16: it keeps
focus, so hovering, tapping or focusing it shows the tooltip. */
.push {
/* An action with nothing behind it yet (Push to Calendar until Feature 16,
the shopping list until Feature 17): it keeps focus, so hovering, tapping
or focusing it shows the tooltip. */
.unavailable {
position: relative;
display: flex;
flex-direction: column;
@@ -243,18 +271,24 @@
min-height: 50px;
}
.pushButton[aria-disabled='true'] {
.unavailable [aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
/* Unavailable, it neither darkens nor presses. */
.push .pushButton[aria-disabled='true']:hover,
.push .pushButton[aria-disabled='true']:active {
.unavailable .primary[aria-disabled='true']:hover,
.unavailable .primary[aria-disabled='true']:active {
background: var(--color-accent);
transform: none;
}
.unavailable .outline[aria-disabled='true']:hover,
.unavailable .outline[aria-disabled='true']:active {
background: transparent;
transform: none;
}
.hint {
position: absolute;
bottom: calc(100% + 8px);
@@ -275,7 +309,281 @@
pointer-events: none;
}
.push:hover .hint,
.pushButton:focus + .hint {
/* In the plan manager's header the tooltip opens below, at the action's
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;
}
/* 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;
}
+775 -29
View File
@@ -1,5 +1,6 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
@@ -8,10 +9,11 @@ import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { PlanPage } from './PlanPage.tsx'
import { ago, cooking, duration, PlanPage } from './PlanPage.tsx'
const THIS_WEEK = '/api/v1/plans/2026-10-05'
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28'
const DRAFT = `${THIS_WEEK}/draft`
const JOB = '/api/v1/jobs/j1'
@@ -21,7 +23,8 @@ const NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Crock pot tikka masala', 'C
// planBody is the plan of the week of Oct 5 as GET /api/v1/plans/{weekStart}
// sends it: Wednesday's crock-pot tikka masala and Saturday's bowls are
// Quest-log picks, and the tikka masala must start by 1:30 pm New York time
// to be ready for 5:30 pm dinner.
// to be ready for 5:30 pm dinner. Monday's tacos have a stored photo. A plan
// pushed to Calendar was pushed two minutes before the tests' noon.
function planBody(status: string, names = NAMES) {
return {
id: 'plan-1',
@@ -29,13 +32,13 @@ function planBody(status: string, names = NAMES) {
week_label: 'Week 41 · Oct 5 – 11',
status,
ai_note: 'I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it.',
calendar_synced_at: status === 'draft' ? null : '2026-10-04T15:00:00Z',
calendar_synced_at: status === 'draft' ? null : '2026-10-07T15:58:00Z',
days: DAYS.map((date, i) => ({
date,
dinner: {
id: `d${i}`,
name: names[i],
photo: null,
photo: i === 0 ? '/media/photos/tacos-400.jpg' : null,
minutes: i === 2 ? 240 : 30,
method: i === 2 ? 'crockpot' : 'stovetop',
origin: i === 2 || i === 5 ? 'side_quest' : 'regular',
@@ -56,6 +59,31 @@ function draftJob(status: string, error: string | null = null, planId = 'plan-1'
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
const QUEST_LOG = '/api/v1/dinners?status=quest_log'
const REGULARS = '/api/v1/dinners?status=regular'
// dinner is a dinner as the dinners API lists it.
function dinner(id: string, name: string, difficulty: string | null, minutes: number | null) {
return {
id,
name,
method: 'stovetop',
minutes_total: minutes,
difficulty,
is_favorite: false,
photo_url: null,
grounding_status: 'grounded',
}
}
// The Quest log has lemon orzo; the Regulars are Monday's tacos, already
// this week, and fish tacos.
const lists = {
[`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25)] }),
[`GET ${REGULARS}`]: () =>
jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }),
}
// stubServer answers "METHOD path" requests from responders, with /me and
// the household settings; any other request fails the test.
function stubServer(responders: Record<string, Responder>) {
@@ -94,7 +122,9 @@ async function renderPage() {
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<PlanPage />
<MemoryRouter initialEntries={['/plan']}>
<PlanPage />
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
@@ -423,32 +453,8 @@ describe('PlanPage', () => {
})
describe('swap sheet', () => {
const QUEST_LOG = '/api/v1/dinners?status=quest_log'
const REGULARS = '/api/v1/dinners?status=regular'
const FRIDAY = `${THIS_WEEK}/days/2026-10-09`
// dinner is a dinner as the dinners API lists it.
function dinner(id: string, name: string, difficulty: string | null, minutes: number | null) {
return {
id,
name,
method: 'stovetop',
minutes_total: minutes,
difficulty,
is_favorite: false,
photo_url: null,
grounding_status: 'grounded',
}
}
// The Quest log has lemon orzo; the Regulars are Monday's tacos, already
// this week, and fish tacos.
const lists = {
[`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25)] }),
[`GET ${REGULARS}`]: () =>
jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }),
}
// swappedBody is the plan as the server answers a swap of Friday to
// lemon orzo, a Quest-log pick.
function swappedBody() {
@@ -681,3 +687,743 @@ describe('PlanPage', () => {
})
})
})
describe.each([
{ width: 768, layout: 'tablet', photoHeight: '96px', nameSize: '16px' },
{ width: 1180, layout: 'tablet', photoHeight: '96px', nameSize: '16px' },
{ width: 1440, layout: 'desktop', photoHeight: '110px', nameSize: '17px' },
])('PlanPage at $width px (mocks 2c, 2d)', ({ width, layout, photoHeight, nameSize }) => {
beforeEach(() => mockViewport(width))
// column is the grid's column of a day, by its name: "Monday".
function column(day: string) {
return within(screen.getByRole('list', { name: 'Dinners this week' })).getByRole('listitem', { name: day })
}
// card is the card in a day's column, holding its dinner.
function card(day: string, dinnerName: string) {
return within(column(day)).getByText(dinnerName).parentElement!
}
it('renders the plan manager: header, actions, sync status and the 7-column grid', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
await renderPage()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
expect(screen.getByText('Week 41 · Plan')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Previous week' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
// Actions: the shopping list and Push to Calendar unavailable, saying
// why; Rebuild week outlined and working.
const shopping = screen.getByRole('button', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('aria-disabled', 'true')
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
const push = screen.getByRole('button', { name: 'Push to Calendar' })
expect(push).toHaveAttribute('aria-disabled', 'true')
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
expect(declaredStyle(push).background).toBe('var(--color-accent)')
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
expect(rebuild).toBeEnabled()
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
fireEvent.click(shopping)
fireEvent.click(push)
expect(posts(fetchMock)).toEqual([])
// Mobile's own pieces are not shown.
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument()
// Seven columns, Monday to Sunday, 12 px apart.
const grid = screen.getByRole('list', { name: 'Dinners this week' })
expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(7, minmax(112px, 1fr))')
expect(declaredStyle(grid).gap).toBe('12px')
expect(within(grid).getAllByRole('listitem').map((li) => li.getAttribute('aria-label'))).toEqual([
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
])
// Monday: day label, the washed photo, the name in Bagel, method and
// minutes, and the Regular tag, on the surface.
expect(column('Monday')).toHaveTextContent(/^MON5/)
const monday = card('Monday', 'Beef tacos')
expect(declaredStyle(monday).background).toBe('var(--color-surface)')
const photo = within(monday).getByRole('img', { hidden: true, name: 'Beef tacos' })
expect(photo).toHaveAttribute('src', '/media/photos/tacos-400.jpg')
expect(declaredStyle(photo).height).toBe(photoHeight)
const name = within(monday).getByText('Beef tacos')
expect(declaredStyle(name).fontFamily).toBe('var(--font-heading)')
expect(declaredStyle(name).fontSize).toBe(nameSize)
// Testing Library reads the duration's no-break spaces as spaces.
expect(within(monday).getByText('Stovetop · 30 min')).toBeInTheDocument()
expect(declaredStyle(within(monday).getByText('Regular')).background).toBe('var(--color-neutral-100)')
expect(within(monday).queryByText(/Start by/)).not.toBeInTheDocument()
// Wednesday: a crock-pot Side quest on teal-200, with its start-by time.
const wednesday = card('Wednesday', 'Crock pot tikka masala')
expect(declaredStyle(wednesday).background).toBe('var(--color-accent-2-200)')
expect(within(wednesday).getByText('Crock pot · 4 hr')).toBeInTheDocument()
expect(within(wednesday).getByText('Start by 1:30 pm')).toBeInTheDocument()
expect(within(wednesday).getByText('Side quest')).toBeInTheDocument()
expect(within(column('Wednesday')).getByRole('button', { name: 'Swap Wednesday' })).toBeInTheDocument()
})
it('says a synced plan is on Google Calendar, and how long ago it was pushed', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')), ...lists })
await renderPage()
const status = await screen.findByText('Synced to Google Calendar 2 min ago')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
// The time is kept current.
act(() => vi.advanceTimersByTime(58 * 60_000))
expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago')
})
it('says changes to a pushed plan are not on Google Calendar', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists })
await renderPage()
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
})
if (layout === 'desktop') {
it('shows the Quest log and Regulars in the 290 px right panel', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
await renderPage()
const panel = screen.getByRole('complementary', { name: 'Quest log and Regulars' })
expect(declaredStyle(panel).borderLeft).toBe('1px solid var(--color-divider)')
expect(declaredStyle(panel.parentElement!).gridTemplateColumns).toBe('minmax(0, 1fr) 290px')
expect(await within(panel).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument()
expect(await within(panel).findByRole('radio', { name: 'Regulars · 2' })).toBeInTheDocument()
expect(within(panel).getByText('Drag a dinner onto a day to swap it in.')).toBeInTheDocument()
const orzo = within(within(panel).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
expect(within(orzo).getByText('Lemon orzo').nextElementSibling).toHaveTextContent('Easy')
expect(declaredStyle(within(orzo).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('48px')
// The 6-dot grip, last in the row's button.
expect(within(orzo).getByRole('button').lastElementChild!.children).toHaveLength(6)
fireEvent.click(within(panel).getByRole('radio', { name: 'Regulars · 2' }))
const regulars = within(within(panel).getByRole('list', { name: 'Regulars' })).getAllByRole('listitem')
expect(regulars).toHaveLength(2)
expect(within(regulars[0]!).getByText('Beef tacos').nextElementSibling).toHaveTextContent('Easy')
expect(within(regulars[1]!).getByText('Fish tacos').nextElementSibling).toHaveTextContent('Medium')
expect(within(panel).getByRole('link', { name: 'Swipe more side quests' })).toHaveAttribute('href', '/discover')
expect(screen.queryByRole('region', { name: 'Quest log' })).not.toBeInTheDocument()
})
} else {
it('shows the Quest log as a strip of chips under the grid', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
await renderPage()
const strip = screen.getByRole('region', { name: 'Quest log' })
expect(await within(strip).findByRole('heading', { name: 'Quest log · 1' })).toBeInTheDocument()
expect(within(strip).getByText('Drag a dinner onto a day to swap it in.')).toBeInTheDocument()
const chip = within(within(strip).getByRole('list', { name: 'Quest log dinners' })).getByRole('listitem')
expect(chip).toHaveTextContent('Lemon orzo')
expect(declaredStyle(within(chip).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('28px')
// The strip follows the grid.
const grid = await screen.findByRole('list', { name: 'Dinners this week' })
expect(grid.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
})
}
it('moves between weeks with the previous and next week buttons', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'),
...lists,
})
await renderPage()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
expect(screen.getByText('Week 42 · Plan')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument()
expect(screen.getByText('Week 40 · Plan')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
})
if (layout === 'desktop') {
it('comes back to this week with the "This week" pill', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
...lists,
})
await renderPage()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
const thisWeek = screen.getByRole('button', { name: 'This week' })
expect(declaredStyle(thisWeek).background).toBe('var(--color-surface)')
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'This week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
})
it("comes back to this week even when the plan opened on next week's", async () => {
vi.setSystemTime(new Date('2026-10-09T13:00:00Z')) // Friday 09:00 in New York
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
...lists,
})
await renderPage()
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'This week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
})
}
it("opens the swap sheet from a day's swap icon and swaps the dinner in", async () => {
const FRIDAY = `${THIS_WEEK}/days/2026-10-09`
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...lists,
[`PUT ${FRIDAY}`]: () => new Promise<Response>(() => undefined),
})
await renderPage()
fireEvent.click(within(await waitFor(() => column('Friday'))).getByRole('button', { name: 'Swap Friday' }))
const sheet = await screen.findByRole('dialog', { name: 'Swap Friday' })
expect(within(sheet).getByText('Replaces Pizza night')).toBeInTheDocument()
// The card being swapped is ringed in grape.
expect(declaredStyle(card('Friday', 'Pizza night')).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)')
fireEvent.keyDown(sheet, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(declaredStyle(card('Friday', 'Pizza night')).boxShadow).toBe('')
fireEvent.click(within(column('Friday')).getByRole('button', { name: 'Swap Friday' }))
const again = await screen.findByRole('dialog', { name: 'Swap Friday' })
fireEvent.click(await within(again).findByRole('button', { name: /Lemon orzo/ }))
// Optimistic: the sheet is closed and Friday is the Side quest, on teal.
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
const friday = await waitFor(() => card('Friday', 'Lemon orzo'))
expect(within(friday).getByText('Side quest')).toBeInTheDocument()
expect(within(friday).getByText('Stovetop · 25 min')).toBeInTheDocument()
expect(declaredStyle(friday).background).toBe('var(--color-accent-2-200)')
await waitFor(() => expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(1))
// Rebuild week waits for the swap.
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled()
})
it.each(['synced', 'changed'])('asks before rebuilding a %s plan, and sends confirm', async (status) => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody(status)),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')),
...lists,
})
await renderPage()
await screen.findByText('Beef tacos')
fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' }))
const dialog = await screen.findByRole('dialog', { name: 'Rebuild this week?' })
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep this plan' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(posts(fetchMock)).toEqual([])
fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Rebuild week' }))
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {"confirm":true}`]))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7)
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled()
})
it('rebuilds a draft at once', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')),
...lists,
})
await renderPage()
await screen.findByText('Beef tacos')
fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]))
})
it('reads the Quest log again once a draft finishes, dropping the dinners it planned', async () => {
let plan = planBody('draft')
let job = draftJob('running')
let questLog = [dinner('q1', 'Lemon orzo', 'easy', 25)]
stubServer({
...lists,
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
[`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog }),
})
await renderPage()
const list = await screen.findByRole('list', { name: layout === 'desktop' ? 'Quest log' : 'Quest log dinners' })
expect(await within(list).findByText('Lemon orzo')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' }))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
plan = planBody('draft', ['Lemon orzo', ...NAMES.slice(1)])
questLog = []
job = draftJob('succeeded')
const week = await screen.findByRole('list', { name: 'Dinners this week' }, { timeout: 3000 })
expect(await within(week).findByText('Lemon orzo', {}, { timeout: 3000 })).toBeInTheDocument()
await waitFor(() => expect(within(list).queryByText('Lemon orzo')).not.toBeInTheDocument())
})
})
// Dragging a dinner from desktop's panel or tablet's strip onto a day swaps
// it in, by pointer or keyboard.
describe.each([
{ width: 1180, layout: 'tablet' },
{ width: 1440, layout: 'desktop' },
])('PlanPage drag and drop at $width px', ({ width, layout }) => {
const FRIDAY = `${THIS_WEEK}/days/2026-10-09`
let capture: Mock<(pointerId: number) => void>
// The element document.elementFromPoint finds under the pointer.
let hovered: Element | null = null
// jsdom has no pointer capture and no layout to hit-test.
beforeEach(() => {
mockViewport(width)
capture = vi.fn()
Element.prototype.setPointerCapture = capture
hovered = null
document.elementFromPoint = () => hovered
})
afterEach(() => {
Reflect.deleteProperty(Element.prototype, 'setPointerCapture')
Reflect.deleteProperty(document, 'elementFromPoint')
})
// The Quest log has lemon orzo and miso salmon.
const dragLists = {
...lists,
[`GET ${QUEST_LOG}`]: () =>
jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25), dinner('q2', 'Miso salmon', 'medium', 30)] }),
}
// swappedBody is the plan as the server answers a swap of Friday to lemon
// orzo, a Quest-log pick.
function swappedBody() {
const body = planBody('draft')
body.days[4] = { ...body.days[4]!, dinner: { ...body.days[4]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true }
return body
}
function puts(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === 'PUT')
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
function column(day: string) {
return within(screen.getByRole('list', { name: 'Dinners this week' })).getByRole('listitem', { name: day })
}
// quest is the Quest-log dinner's draggable: a row of the panel on
// desktop, a chip of the strip on tablet.
async function quest(name: string) {
const list = await screen.findByRole('list', { name: layout === 'desktop' ? 'Quest log' : 'Quest log dinners' })
return within(list).getByRole('button', { name: new RegExp(name) })
}
const announcer = () => screen.getByTestId('drag-announcer')
const float = () => screen.queryByTestId('drag-float')
// press, move and release drive pointer 3 on the draggable: down at
// (100, 100), moved over an element (or none) at (160, 140) or the given
// point, and let go there.
function press(draggable: Element) {
fireEvent.pointerDown(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 100, clientY: 100 })
}
function move(draggable: Element, over: Element | null, [clientX, clientY] = [160, 140]) {
hovered = over
fireEvent.pointerMove(draggable, { pointerId: 3, isPrimary: true, clientX, clientY })
}
function release(draggable: Element) {
fireEvent.pointerUp(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 160, clientY: 140 })
}
it('drops a dinner dragged onto a day, swapping it in at once and landing the plan the server answers', async () => {
let answer!: (response: Response) => void
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...dragLists,
[`PUT ${FRIDAY}`]: () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
expect(declaredStyle(orzo).getPropertyValue('touch-action')).toBe('none')
expect(orzo).toHaveAccessibleDescription(
'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.',
)
press(orzo)
expect(capture).toHaveBeenCalledExactlyOnceWith(3)
expect(capture.mock.contexts[0]).toBe(orzo)
// A press alone is no drag.
expect(float()).not.toBeInTheDocument()
// Over Friday: the source a dashed ghost at 55%, its copy floating with
// the large shadow at -3deg, and Friday the drop target.
move(orzo, within(column('Friday')).getByText('Pizza night'))
expect(declaredStyle(orzo).opacity).toBe('0.55')
expect(declaredStyle(orzo).borderStyle).toBe('dashed')
const copy = float()!
expect(copy).toHaveTextContent('Lemon orzo')
expect(copy).toHaveAttribute('aria-hidden', 'true')
expect(declaredStyle(copy).boxShadow).toBe('var(--shadow-lg)')
expect(declaredStyle(copy).transform).toBe('rotate(-3deg)')
expect(declaredStyle(copy).pointerEvents).toBe('none')
// Offset 48 px below right of the pointer at (160, 140), clear of the
// day's text under it.
expect(copy.style.left).toBe('208px')
expect(copy.style.top).toBe('188px')
expect(announcer()).toHaveTextContent('Picked up Lemon orzo.')
const target = within(column('Friday')).getByText('Drop to swap').parentElement!
expect(target).toHaveTextContent('Drop to swapReplaces Pizza night')
expect(declaredStyle(target).border).toBe('2.5px dashed var(--color-accent)')
expect(declaredStyle(target).background).toBe('var(--color-accent-100)')
expect(within(column('Thursday')).queryByText('Drop to swap')).not.toBeInTheDocument()
// Moving on to Thursday moves the target.
move(orzo, column('Thursday'))
expect(within(column('Friday')).queryByText('Drop to swap')).not.toBeInTheDocument()
expect(within(column('Thursday')).getByText('Replaces Chicken stir-fry')).toBeInTheDocument()
move(orzo, within(column('Friday')).getByRole('button', { name: 'Swap Friday' }))
release(orzo)
expect(float()).not.toBeInTheDocument()
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(declaredStyle(orzo).opacity).toBe('')
expect(announcer()).toHaveTextContent('Dropped Lemon orzo on Friday, replacing Pizza night.')
// Optimistic: Friday is the Side quest at once.
const friday = await waitFor(() => within(column('Friday')).getByText('Lemon orzo').parentElement!)
expect(within(friday).getByText('Side quest')).toBeInTheDocument()
await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`]))
await act(async () => answer(jsonResponse(200, swappedBody())))
expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument()
expect(screen.queryByText('Pizza night')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(puts(fetchMock)).toHaveLength(1)
})
it('flips the floating copy to the left of and above the pointer where it would leave the viewport', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists })
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
vi.spyOn(orzo, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ x: 90, y: 90, width: 240, height: 60 }))
press(orzo)
const [x, y] = [window.innerWidth - 100, window.innerHeight - 40]
move(orzo, document.body, [x, y])
const copy = float()!
expect(copy.style.width).toBe('240px')
expect(copy.style.left).toBe(`${x - 48 - 240}px`)
expect(copy.style.top).toBe(`${y - 48 - 60}px`)
release(orzo)
})
it('cancels a drag let go over no day, or cancelled, with no swap', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists })
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
press(orzo)
move(orzo, column('Friday'))
expect(within(column('Friday')).getByText('Drop to swap')).toBeInTheDocument()
// Off the grid, onto the page around it.
move(orzo, document.body)
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(float()).toBeInTheDocument()
release(orzo)
expect(float()).not.toBeInTheDocument()
expect(declaredStyle(orzo).opacity).toBe('')
expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.')
// A cancelled pointer, over a day, is no drop either.
press(orzo)
move(orzo, column('Monday'))
expect(within(column('Monday')).getByText('Replaces Beef tacos')).toBeInTheDocument()
fireEvent.pointerCancel(orzo, { pointerId: 3, isPrimary: true })
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(float()).not.toBeInTheDocument()
// Nor is a drag Escape ends.
press(orzo)
move(orzo, column('Monday'))
fireEvent.keyDown(orzo, { key: 'Escape' })
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
release(orzo)
expect(within(column('Friday')).getByText('Pizza night')).toBeInTheDocument()
expect(within(column('Monday')).getByText('Beef tacos')).toBeInTheDocument()
expect(puts(fetchMock)).toEqual([])
})
it('puts the day back and shows the failure when the dropped swap is refused', async () => {
let refuse!: () => void
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...dragLists,
[`PUT ${FRIDAY}`]: () => new Promise<Response>((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))),
})
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
press(orzo)
move(orzo, column('Friday'))
release(orzo)
await waitFor(() => expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument())
await waitFor(() => expect(puts(fetchMock)).toHaveLength(1))
// While the swap is pending no other drag starts, by pointer or keyboard.
const salmon = await quest('Miso salmon')
expect(salmon).toHaveAttribute('aria-disabled', 'true')
press(salmon)
move(salmon, column('Monday'))
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(float()).not.toBeInTheDocument()
release(salmon)
fireEvent.keyDown(salmon, { key: 'Enter' })
expect(announcer()).toHaveTextContent('A swap is still being saved. Pick Miso salmon up once it is done.')
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
await act(async () => refuse())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
expect(within(column('Friday')).getByText('Pizza night')).toBeInTheDocument()
expect(within(column('Friday')).queryByText('Lemon orzo')).not.toBeInTheDocument()
expect(puts(fetchMock)).toHaveLength(1)
await waitFor(() => expect(salmon).not.toHaveAttribute('aria-disabled'))
})
it('swaps a dinner in with the keyboard, announcing each step', async () => {
let answer!: (response: Response) => void
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...dragLists,
[`PUT ${FRIDAY}`]: () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
expect(announcer()).toHaveAttribute('aria-live', 'polite')
// Enter picks it up onto Monday.
orzo.focus()
fireEvent.keyDown(orzo, { key: 'Enter' })
expect(announcer()).toHaveTextContent(
'Picked up Lemon orzo. Monday: replaces Beef tacos. Use the arrow keys to choose a day, Enter to drop, Escape to cancel.',
)
expect(declaredStyle(orzo).opacity).toBe('0.55')
expect(within(column('Monday')).getByText('Replaces Beef tacos')).toBeInTheDocument()
// No pointer, so no floating copy.
expect(float()).not.toBeInTheDocument()
// The arrow keys choose the day, going no further than Monday.
fireEvent.keyDown(orzo, { key: 'ArrowLeft' })
expect(within(column('Monday')).getByText('Drop to swap')).toBeInTheDocument()
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
expect(announcer()).toHaveTextContent('Tuesday: replaces Spaghetti bolognese.')
expect(within(column('Monday')).queryByText('Drop to swap')).not.toBeInTheDocument()
fireEvent.keyDown(orzo, { key: 'ArrowDown' })
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
fireEvent.keyDown(orzo, { key: 'ArrowUp' })
expect(announcer()).toHaveTextContent('Friday: replaces Pizza night.')
expect(within(column('Friday')).getByText('Replaces Pizza night')).toBeInTheDocument()
expect(puts(fetchMock)).toEqual([])
// Enter drops it, swapping it in at once; focus moves to the day.
fireEvent.keyDown(orzo, { key: 'Enter' })
expect(announcer()).toHaveTextContent('Dropped Lemon orzo on Friday, replacing Pizza night.')
expect(screen.getByRole('button', { name: 'Swap Friday' })).toHaveFocus()
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
await waitFor(() => expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument())
await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`]))
await act(async () => answer(jsonResponse(200, swappedBody())))
expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument()
expect(puts(fetchMock)).toHaveLength(1)
})
it('cancels a keyboard drag with Escape, or by leaving it', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists })
await renderPage()
await screen.findByText('Pizza night')
const orzo = await quest('Lemon orzo')
// Space picks it up too, but does not drop it.
orzo.focus()
fireEvent.keyDown(orzo, { key: ' ' })
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
fireEvent.keyDown(orzo, { key: ' ' })
expect(within(column('Tuesday')).getByText('Drop to swap')).toBeInTheDocument()
fireEvent.keyDown(orzo, { key: 'Escape' })
expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.')
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(declaredStyle(orzo).opacity).toBe('')
expect(orzo).toHaveFocus()
// The arrow keys do nothing until it is picked up again; Enter does.
fireEvent.keyDown(orzo, { key: 'ArrowRight' })
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
fireEvent.keyDown(orzo, { key: 'Enter' })
expect(within(column('Monday')).getByText('Drop to swap')).toBeInTheDocument()
// Tabbing away cancels.
fireEvent.blur(orzo)
expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.')
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
expect(within(column('Monday')).getByText('Beef tacos')).toBeInTheDocument()
expect(puts(fetchMock)).toEqual([])
})
if (layout === 'desktop') {
it('does not pick up a Regular already this week', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
...dragLists,
[`PUT ${THIS_WEEK}/days/2026-10-07`]: () => new Promise<Response>(() => undefined),
})
await renderPage()
await screen.findByText('Pizza night')
fireEvent.click(await screen.findByRole('radio', { name: 'Regulars · 2' }))
const regulars = screen.getByRole('list', { name: 'Regulars' })
const tacos = within(regulars).getByRole('button', { name: /Beef tacos/ })
expect(tacos).toHaveAttribute('aria-disabled', 'true')
fireEvent.keyDown(tacos, { key: 'Enter' })
expect(announcer()).toHaveTextContent('Beef tacos is already this week.')
expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument()
// A Regular not this week is dragged in as one, not NEW.
const fish = within(regulars).getByRole('button', { name: /Fish tacos/ })
expect(fish).not.toHaveAttribute('aria-disabled')
press(fish)
move(fish, column('Wednesday'))
release(fish)
await waitFor(() => expect(within(column('Wednesday')).getByText('Fish tacos').parentElement).toHaveTextContent('Regular'))
await waitFor(() => expect(puts(fetchMock)).toEqual([`${THIS_WEEK}/days/2026-10-07 {"dinner_id":"r1"}`]))
})
}
})
// Mobile's Draft board has no drag: its tiles open the swap sheet.
describe('PlanPage drag and drop on mobile', () => {
it('has no draggables, drop targets or drag announcements', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
const { container } = await renderPage()
await screen.findByText('Pizza night')
expect(container.querySelector('[data-drop-date]')).toBeNull()
expect(screen.queryByTestId('drag-announcer')).not.toBeInTheDocument()
expect(screen.queryByText('Drag a dinner onto a day to swap it in.')).not.toBeInTheDocument()
})
})
// At tablet's narrow end seven usable columns do not fit beside the rail:
// the week keeps its columns usable and scrolls sideways.
describe('PlanPage at 768 px', () => {
beforeEach(() => mockViewport(768))
it('keeps each day column usable and scrolls the week sideways', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
await renderPage()
expect(await screen.findByText('Spaghetti bolognese')).toBeInTheDocument()
const grid = screen.getByRole('list', { name: 'Dinners this week' })
// Columns no narrower than 112 px; the week scrolls when seven do not
// fit, its padding keeping the swap icons' hit boxes inside.
expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(7, minmax(112px, 1fr))')
expect(declaredStyle(grid).overflowX).toBe('auto')
expect(declaredStyle(grid).padding).toBe('8px')
expect(declaredStyle(grid).margin).toBe('-8px')
const tuesday = within(grid).getByRole('listitem', { name: 'Tuesday' })
// The name wraps between words, only breaking a word wider than the card.
const name = within(tuesday).getByText('Spaghetti bolognese')
expect(declaredStyle(name).overflowWrap).toBe('break-word')
expect(declaredStyle(name).hyphens).toBe('')
// The swap icon stays beside the tag.
const footer = within(tuesday).getByRole('button', { name: 'Swap Tuesday' }).parentElement!
expect(within(footer).getByText('Regular')).toBeInTheDocument()
expect(declaredStyle(footer).display).toBe('flex')
expect(declaredStyle(footer).flexWrap).toBe('')
// Every piece of the card is still there.
expect(within(tuesday).getByRole('img', { hidden: true, name: 'Spaghetti bolognese' })).toBeInTheDocument()
expect(within(tuesday).getByText('Stovetop · 30 min')).toBeInTheDocument()
expect(within(within(grid).getByRole('listitem', { name: 'Wednesday' })).getByText('Start by 1:30 pm')).toBeInTheDocument()
})
})
describe('duration', () => {
it.each([
[25, '25\u00a0min'],
[60, '1\u00a0hr'],
[90, '1\u00a0hr\u00a030\u00a0min'],
[360, '6\u00a0hr'],
])('says %i minutes as %j', (minutes, said) => {
expect(duration(minutes)).toBe(said)
})
})
describe('cooking', () => {
const dinner = { id: 'd1', name: 'Tikka masala', photo_id: undefined }
it.each([
[{ method: 'crockpot' as const, minutes: 360 }, 'Crock pot · 6\u00a0hr'],
[{ method: 'crockpot' as const, minutes: null }, 'Crock pot'],
[{ method: null, minutes: 45 }, '45\u00a0min'],
[{ method: null, minutes: null }, null],
])('says %o as %j', (known, said) => {
expect(cooking({ ...dinner, ...known })).toBe(said)
})
})
describe('ago', () => {
const now = new Date('2026-10-07T16:00:00Z')
it.each([
['2026-10-07T15:59:30Z', 'just now'],
['2026-10-07T15:58:00Z', '2 min ago'],
['2026-10-07T13:00:00Z', '3 hr ago'],
['2026-10-06T15:00:00Z', '1 day ago'],
['2026-10-04T15:00:00Z', '3 days ago'],
])('says %s as %j', (at, said) => {
expect(ago(new Date(at), now)).toBe(said)
})
})
+313 -65
View File
@@ -1,14 +1,18 @@
import { useQueryClient } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
import { useEffect, useId, useMemo, useState } from 'react'
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { useJob } from '../api/index.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
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 { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import {
@@ -19,12 +23,25 @@ import {
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, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel } from './week.ts'
import {
addDays,
dayOfMonth,
defaultWeek,
localNow,
weekdayKicker,
weekdayName,
weekLabel,
weekStart as weekOf,
} from './week.ts'
import styles from './PlanPage.module.css'
// statusTags is the tag beside the week's title for each plan status.
@@ -41,6 +58,7 @@ const draftFailures: Record<string, string> = {
ai_failed: "The AI assistant couldn't draft it.",
confirm_required: 'The plan was pushed to Calendar 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.',
}
@@ -48,6 +66,48 @@ const draftFailures: Record<string, string> = {
// TODO(Feature 16): connect Push to Calendar once Google Calendar is.
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
// confirm: it was pushed to Calendar.
function pushedToCalendar(status: PlanStatus): boolean {
@@ -77,14 +137,60 @@ function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone:
)
}
// 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 && (
<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>
</li>
)
}
// SkeletonTiles stands in for the seven days while the plan loads or the AI
// drafts it.
function SkeletonTiles({ label, children }: { label: string; children?: string }) {
// drafts it, as two columns of tiles on mobile and seven columns wider.
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
const wide = layout !== 'mobile'
return (
<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) => (
<Skeleton key={i} data-testid="skeleton-tile" className={styles.skeletonTile} />
<Skeleton key={i} data-testid="skeleton-tile" className={wide ? styles.skeletonCard : styles.skeletonTile} />
))}
</div>
{children !== undefined && <p className={styles.drafting}>{children}</p>}
@@ -97,7 +203,21 @@ function SkeletonTiles({ label, children }: { label: string; children?: string }
// 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,
// 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 plan = usePlan(weekStart)
const startDraft = useStartDraft()
@@ -121,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
// 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 ended = job.status === 'succeeded' || job.status === 'failed'
useEffect(() => {
if (!ended) return
let active = true
void queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => {
if (active) setReadJobId(jobId)
})
@@ -208,6 +330,12 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
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.
@@ -221,12 +349,16 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
if (plan.isError) {
body = <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load this week's plan. {plan.error.message}</ErrorBanner>
} 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) {
// An unread draft shows no board: the plan read is not the draft.
body = null
} else if (plan.isPending) {
body = <SkeletonTiles label="Plan loading" />
body = <SkeletonTiles label="Plan loading" layout={layout} />
} else if (plan.data === null) {
body = (
<EmptyState title="Nothing planned yet" action={{ label: 'Draft this week', onClick: rebuild }}>
@@ -236,49 +368,108 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
} else {
const { ai_note, days } = plan.data
const swappingDay = days.find((day) => day.date === swappingDate)
body = (
<>
{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>
<PushToCalendar />
{swappingDay !== undefined && (
<SwapSheet
day={swappingDay}
planned={new Set(days.map((day) => day.dinner.id))}
onSwap={swapIn}
onClose={() => setSwappingDate(null)}
/>
)}
</>
const sheet = swappingDay !== undefined && (
<SwapSheet
day={swappingDay}
planned={new Set(days.map((day) => day.dinner.id))}
onSwap={swapIn}
onClose={() => setSwappingDate(null)}
/>
)
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
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}>
<div className={styles.heading}>
<p className={styles.kicker}>{label.week}</p>
@@ -296,6 +487,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
</IconButton>
</div>
</header>
)
return (
<>
{header}
{body}
{failure}
<Confirm
@@ -312,27 +507,69 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
)
}
// PushToCalendar is "Looks good — push to Calendar", unavailable until
// Google Calendar can be connected: it stays focusable, so a tap or focus
// shows the tooltip saying why.
function PushToCalendar() {
// SyncStatus is the plan manager's status line: whether the plan is on
// Google Calendar and, once synced, how long ago it was pushed, kept current
// minute by minute.
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()
return (
<div className={styles.push}>
<Button className={styles.pushButton} aria-disabled="true" aria-describedby={hintId}>
Looks good — push to Calendar
<div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
<Button
variant={variant}
className={[className, styles[variant]].filter(Boolean).join(' ')}
aria-disabled="true"
aria-describedby={hintId}
>
{children}
</Button>
<span id={hintId} role="tooltip" className={styles.hint}>
{PUSH_HINT}
{hint}
</span>
</div>
)
}
// PlanPage is mock 1f, the mobile Draft board, on the week chosen with the
// previous / next week buttons. It opens on next week once this week's
// plan_draft time has come in the household's zone, otherwise on this week.
// PlanPage is the Draft board on the week chosen with the previous / next
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
// 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() {
const layout = useBreakpoint()
const settings = useHouseholdSettings()
const [week, setWeek] = useState<string | null>(null)
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
@@ -343,9 +580,20 @@ export function PlanPage() {
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
)
} else if (week === null || !settings.data) {
content = <SkeletonTiles label="Plan loading" />
content = <SkeletonTiles label="Plan loading" layout={layout} />
} 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>
)
}
+12 -5
View File
@@ -9,19 +9,25 @@ 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, Swap } from './api.ts'
import type { PlanDay, PlanDinner, Swap } from './api.ts'
import { weekdayName } from './week.ts'
import styles from './SwapSheet.module.css'
type Source = 'quest_log' | 'regulars'
// Source is a list a dinner is swapped in from.
export type Source = 'quest_log' | 'regulars'
const sourceNames: Record<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
export const sourceNames: Record<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
const emptyLists: Record<Source, string> = {
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
@@ -86,7 +92,8 @@ export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
onClick={
already
? undefined
: () => onSwap({ date: day.date, dinner: { id: dinner.id, name: dinner.name }, fromQuestLog: source === 'quest_log' })
: () =>
onSwap({ date: day.date, dinner: planDinner(dinner), fromQuestLog: source === 'quest_log' })
}
trailing={already && <Tag>Already this week</Tag>}
/>
+43 -3
View File
@@ -21,11 +21,12 @@ function planBody(changes: Record<number, { id: string; name: string; is_new?: b
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}` },
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,
}
@@ -74,13 +75,52 @@ async function renderSwaps() {
return { ...hook, queryClient, plan: () => queryClient.getQueryData<Plan>(planKey(WEEK))! }
}
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo' }, fromQuestLog: true }
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos' }, fromQuestLog: false }
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.
+40 -7
View File
@@ -1,7 +1,7 @@
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, useApi, type ApiRead } from '../api/index.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'
@@ -22,24 +22,31 @@ export const PLAN_STATUSES = ['draft', 'synced', 'changed'] as const
// synced plan makes it changed.
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
// planned, whether that dinner is a Quest-log pick, and, for a crock-pot
// dinner of known minutes, the instant it must start by.
export type PlanDay = {
date: string
dinner: { id: string; name: string }
dinner: PlanDinner
is_new: boolean
start_by: string | null
}
// 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
// worker answered from its cache: the server may have changed the plan since.
// Sunday, the AI's note on the draft, if any, and when it was last pushed to
// Google Calendar, if ever. cached says the service worker answered from its
// cache: the server may have changed the plan since.
export type Plan = {
id: string
week_start: string
status: PlanStatus
ai_note: string | null
calendar_synced_at: Date | null
days: PlanDay[]
cached: boolean
}
@@ -57,12 +64,30 @@ function parseDay(where: string, body: unknown): PlanDay {
!isRecord(body.dinner) ||
typeof body.dinner.id !== 'string' ||
typeof body.dinner.name !== 'string' ||
(body.dinner.minutes !== null && !Number.isInteger(body.dinner.minutes)) ||
typeof body.is_new !== 'boolean' ||
(body.start_by !== null && typeof body.start_by !== 'string')
) {
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 {
@@ -81,6 +106,7 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool
week_start: weekStart,
status: oneOf(where, 'status', PLAN_STATUSES, body.status),
ai_note: body.ai_note,
calendar_synced_at: syncedAt(where, body.calendar_synced_at),
days: body.days.map((day) => parseDay(where, day)),
cached,
}
@@ -134,12 +160,19 @@ 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: { id: string; name: string }; fromQuestLog: boolean }
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.is_new === b.is_new && a.start_by === b.start_by
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
)
}
+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,
},
}
}
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { addDays, defaultWeek, localNow, weekdayKicker, weekdayName, 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.
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
@@ -36,6 +36,12 @@ describe('week helpers', () => {
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', () => {
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')
+5
View File
@@ -40,6 +40,11 @@ export function weekdayKicker(date: string): string {
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".
+5
View File
@@ -19,3 +19,8 @@
background: var(--color-accent-2-200);
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>)
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 styles from './Tag.module.css'
export type TagTone = 'grape' | 'teal'
export type TagTone = 'grape' | 'teal' | 'neutral'
export type TagProps = ComponentPropsWithRef<'span'> & {
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) {
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
return <span className={classes} {...rest} />