Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers
- Garden and plant dialogs keep centimeters as the source of truth (LengthField in lib/units.ts): a no-change Save no longer rewrites 900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and writing bogus history entries on the way. - The UI stamps every date with the browser's local day (lib/dates.ts). Journal notes already did; plop placement, fill and removal now do too, so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an optional plantedAt; API and agent callers still default to UTC today. - Removing an object that holds plants asks first and says how many go with it. An empty one still goes straight away (one Undo restores it). - The expanded plant card's action row wraps instead of clipping "Delete". - Monogram lettering switches to a dark ink on pale marker colors (garlic, cabbage, marigold) instead of near-white on near-white. - Copy-as-plan proposes the next free year and warns when the typed name already exists, so two gardens can't both read as "the 2027 plan". - Plan cards show the base name with a "2027 plan" tag, so the year — the point of the name — survives truncation. - A rejected model spec now says which model and why: a wrapped ErrInvalidInput's reason reaches the client as the 400's message, and the Settings field shows it inline instead of toasting "invalid input". Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved, which only passed while the real date was before 2026-08-01. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
+17
-1
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -52,7 +53,7 @@ func writeServiceError(c *gin.Context, err error) {
|
||||
case errors.Is(err, domain.ErrOIDCIdentityConflict):
|
||||
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
|
||||
case errors.Is(err, domain.ErrInvalidInput):
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input")
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", inputMessage(err))
|
||||
default:
|
||||
slog.Error("api: unhandled service error", "error", err)
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
|
||||
@@ -114,3 +115,18 @@ func parseIDParam(c *gin.Context, name string) (int64, bool) {
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// inputMessage is the text a 400 carries for an ErrInvalidInput. The bare
|
||||
// sentinel reads "invalid input"; a service that wraps it with a reason —
|
||||
// fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)
|
||||
// — has that reason shown to the person verbatim, minus the sentinel prefix.
|
||||
// So anything wrapped this way is written for the keyboard, not the log (see
|
||||
// the note on domain.ErrInvalidInput).
|
||||
func inputMessage(err error) string {
|
||||
msg := err.Error()
|
||||
base := domain.ErrInvalidInput.Error()
|
||||
if msg == base {
|
||||
return msg
|
||||
}
|
||||
return strings.TrimPrefix(msg, base+": ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user