diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ec480a23e..d49e94d12 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1229,6 +1229,13 @@ export const en = { paletteTerracotta: "Terracotta", dateFormatLabel: "Date format", timeFormatLabel: "Time format", + // #452 — the dropdown's escape hatch to the free-text field, and that + // field's own labels (distinct from dateFormatLabel/timeFormatLabel, + // the dropdown's labels, since both a select and a text field are on + // screen at once while "Custom…" is chosen). + customFormatOption: "Custom…", + customDateFormatLabel: "Custom date format", + customTimeFormatLabel: "Custom time format", savingButton: "Saving…", saveButton: "Save settings", effectNote: @@ -2420,6 +2427,14 @@ export const en = { + "Animated images are refused rather than flattened. What gets stored is a rebuilt copy: camera and " + "location details are stripped out on the way in — a photo taken on a phone carries where it was " + "taken, and for a farm that is its address. Remove it and the sidebar goes back to the Cluckwork mark.", + // #452 — the preset dropdown + Custom escape hatch for date/time format + // overrides, replacing what used to be a bare free-text field. + farmSettingsDateTimeFormat: + "Date format and time format offer a few common choices in a " + + "dropdown — pick one and you are done. Need something not listed? Choose " + + "Custom… to type your own; the presets are all safe choices, but a custom value " + + "is only checked well enough to save, not to prove it looks right — nothing in Cluckwork displays " + + "dates or times through this setting yet, so a broken custom value would not be visible today.", farmSettingsSquareLogo: "Use a square logo. It shows small in the sidebar, so a simple, tightly-cropped mark " + "— a symbol or a single letter — reads much better there than a wide wordmark or a detailed picture, " @@ -2733,8 +2748,9 @@ export const en = { glossaryFarmSettingsTerm: "Farm settings", glossaryFarmSettingsDef: "The farm's name, timezone, locale, currency and unit system, plus optional first day of week and " - + "date/time formats. Setup → Farm settings; owners and managers edit, everyone reads — formatting " - + "money and dates is not a permission.", + + "date/time formats — picked from a preset dropdown, or typed as a custom .NET format string. " + + "Setup → Farm settings; owners and managers edit, everyone reads — formatting money and dates is " + + "not a permission.", glossaryCurrencyLockTerm: "Currency lock", glossaryCurrencyLockDef: diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index d96749eda..334d81059 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -1019,6 +1019,9 @@ export const es = { paletteTerracotta: "Terracota", dateFormatLabel: "Formato de fecha", timeFormatLabel: "Formato de hora", + customFormatOption: "Personalizado…", + customDateFormatLabel: "Formato de fecha personalizado", + customTimeFormatLabel: "Formato de hora personalizado", savingButton: "Guardando…", saveButton: "Guardar configuración", effectNote: @@ -2039,6 +2042,13 @@ export const es = { + "es una copia reconstruida: los detalles de cámara y ubicación se eliminan al ingresar — una foto " + "tomada en un teléfono lleva dónde se tomó, y para una granja eso es su dirección. Elimínelo y la " + "barra lateral vuelve a la marca de Cluckwork.", + farmSettingsDateTimeFormat: + "Formato de fecha y formato de hora ofrecen algunas opciones " + + "comunes en un menú desplegable — elija una y listo. ¿Necesita algo que no está en la lista? " + + "Elija Personalizado… para escribir el suyo; las opciones predefinidas son " + + "siempre seguras, pero un valor personalizado solo se verifica lo suficiente para guardarlo, no " + + "para comprobar que se ve bien — nada en Cluckwork muestra fechas u horas mediante esta " + + "configuración todavía, así que un valor personalizado incorrecto no sería visible hoy.", farmSettingsSquareLogo: "Use un logotipo cuadrado. Se muestra pequeño en la barra lateral, así que una " + "marca simple y bien recortada — un símbolo o una sola letra — se ve mucho mejor ahí que un " @@ -2374,7 +2384,8 @@ export const es = { glossaryFarmSettingsTerm: "Configuración de la granja", glossaryFarmSettingsDef: "El nombre de la granja, la zona horaria, la configuración regional, la moneda y el sistema de " - + "unidades, además del primer día de la semana y los formatos de fecha/hora, opcionales. " + + "unidades, además del primer día de la semana y los formatos de fecha/hora, opcionales — elegidos " + + "de un menú de opciones predefinidas, o escritos como una cadena de formato .NET personalizada. " + "Configuración → Configuración de la granja; propietarios y gerentes editan, todos pueden leer — " + "dar formato a montos y fechas no es un permiso.", diff --git a/web/src/i18n/tl.ts b/web/src/i18n/tl.ts index 39eaefd4c..1204262c8 100644 --- a/web/src/i18n/tl.ts +++ b/web/src/i18n/tl.ts @@ -1057,6 +1057,9 @@ export const tl = { paletteTerracotta: "Terracotta", dateFormatLabel: "Format ng petsa", timeFormatLabel: "Format ng oras", + customFormatOption: "Custom…", + customDateFormatLabel: "Custom na format ng petsa", + customTimeFormatLabel: "Custom na format ng oras", savingButton: "Sine-save…", saveButton: "I-save ang mga setting", effectNote: @@ -2119,6 +2122,13 @@ export const tl = { + "ginawang kopya ang na-save: tinatanggal ang mga detalye ng camera at lokasyon papasok — ang isang " + "larawang kinunan sa telepono ay may kasamang kung saan ito kinuha, at para sa isang bukid, iyon " + "ang address nito. Alisin ito at babalik ang sidebar sa Cluckwork mark.", + farmSettingsDateTimeFormat: + "Nag-aalok ang format ng petsa at format ng oras ng ilang karaniwang " + + "pagpipilian sa isang dropdown — pumili at tapos na. Kailangan ng hindi nakalista? Piliin ang " + + "Custom… para mag-type ng sarili mo; palaging ligtas ang mga preset, pero " + + "sinusuri lang ang custom value nang sapat para ma-save, hindi para patunayan na tama ang " + + "itsura nito — wala pang nagpapakita ng petsa o oras gamit ang setting na ito sa Cluckwork, " + + "kaya hindi makikita ngayon ang isang maling custom value.", farmSettingsSquareLogo: "Gumamit ng parisukat na logo. Maliit itong lumalabas sa sidebar, kaya isang simple " + "at maigsing-crop na marka — isang simbolo o iisang letra — ang mas magandang tingnan doon kaysa " @@ -2461,7 +2471,8 @@ export const tl = { glossaryFarmSettingsTerm: "Mga Setting ng Bukid", glossaryFarmSettingsDef: "Ang pangalan ng bukid, time zone, locale, currency, at unit system, kasama ang opsyonal na unang " - + "araw ng linggo at mga format ng petsa/oras. Setup → Mga Setting ng Bukid; nag-e-edit ang mga " + + "araw ng linggo at mga format ng petsa/oras — pinipili mula sa dropdown ng mga preset, o " + + "ini-type bilang custom na .NET format string. Setup → Mga Setting ng Bukid; nag-e-edit ang mga " + "may-ari at manager, nakakabasa ang lahat — hindi permission ang pag-format ng pera at petsa.", glossaryCurrencyLockTerm: "Currency lock", diff --git a/web/src/routes/HelpPage.tsx b/web/src/routes/HelpPage.tsx index 3c14faa17..4f9b2aa7a 100644 --- a/web/src/routes/HelpPage.tsx +++ b/web/src/routes/HelpPage.tsx @@ -391,6 +391,10 @@ export function HelpPage() {
  • }} />
  • + {/* #452 — preset dropdown + Custom escape hatch for date/time format overrides. */} +
  • + }} /> +
  • }} />
  • diff --git a/web/src/routes/SettingsPage.test.tsx b/web/src/routes/SettingsPage.test.tsx index 3f8f3883a..51e94f1f0 100644 --- a/web/src/routes/SettingsPage.test.tsx +++ b/web/src/routes/SettingsPage.test.tsx @@ -129,6 +129,32 @@ describe("SettingsPage loading", () => { expect(screen.getByLabelText("Time format")).toHaveValue(""); }); + it("opens the date/time format dropdown on Custom… when the stored value isn't one of the presets (#452)", async () => { + mockGetSettings.mockResolvedValue(SETTINGS({ + dateFormatOverride: "dd-MM-yy", timeFormatOverride: "HHmm", + })); + render(); + await screen.findByLabelText("Farm name"); + + expect(screen.getByLabelText("Date format")).toHaveValue("__custom__"); + expect(screen.getByLabelText("Custom date format")).toHaveValue("dd-MM-yy"); + expect(screen.getByLabelText("Time format")).toHaveValue("__custom__"); + expect(screen.getByLabelText("Custom time format")).toHaveValue("HHmm"); + }); + + it("opens the date/time format dropdown on the matching preset, with no custom field, for a stored preset value (#452)", async () => { + mockGetSettings.mockResolvedValue(SETTINGS({ + dateFormatOverride: "yyyy-MM-dd", timeFormatOverride: "HH:mm", + })); + render(); + await screen.findByLabelText("Farm name"); + + expect(screen.getByLabelText("Date format")).toHaveValue("yyyy-MM-dd"); + expect(screen.queryByLabelText("Custom date format")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Time format")).toHaveValue("HH:mm"); + expect(screen.queryByLabelText("Custom time format")).not.toBeInTheDocument(); + }); + it("reports a failed load instead of an empty form", async () => { mockGetSettings.mockRejectedValue(new Error("offline")); render(); @@ -171,7 +197,11 @@ describe("SettingsPage saving", () => { await renderReady(SETTINGS({ firstDayOfWeek: "Monday", dateFormatOverride: "dd/MM/yyyy" })); fireEvent.change(screen.getByLabelText("First day of week"), { target: { value: "" } }); - fireEvent.change(screen.getByLabelText("Date format"), { target: { value: " " } }); + // #452 — realistic path: switch the dropdown to "Custom…", which reveals + // the free-text field, THEN type whitespace into that field — a select + // itself has no way to hold an arbitrary typed value. + fireEvent.change(screen.getByLabelText("Date format"), { target: { value: "__custom__" } }); + fireEvent.change(screen.getByLabelText("Custom date format"), { target: { value: " " } }); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); const [body] = mockUpdate.mock.calls[0]; @@ -179,6 +209,38 @@ describe("SettingsPage saving", () => { expect(body.dateFormatOverride).toBeNull(); }); + it("sends a preset picked directly from the date/time format dropdowns, no custom field involved (#452)", async () => { + mockUpdate.mockResolvedValue(undefined); + await renderReady(); + + fireEvent.change(screen.getByLabelText("Date format"), { target: { value: "yyyy-MM-dd" } }); + fireEvent.change(screen.getByLabelText("Time format"), { target: { value: "HH:mm" } }); + expect(screen.queryByLabelText("Custom date format")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Custom time format")).not.toBeInTheDocument(); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); + + const [body] = mockUpdate.mock.calls[0]; + expect(body.dateFormatOverride).toBe("yyyy-MM-dd"); + expect(body.timeFormatOverride).toBe("HH:mm"); + }); + + it("reveals the custom field on Custom…, and re-hides it (discarding the typed value) if a preset is picked afterward (#452)", async () => { + mockUpdate.mockResolvedValue(undefined); + await renderReady(); + + fireEvent.change(screen.getByLabelText("Date format"), { target: { value: "__custom__" } }); + const customField = screen.getByLabelText("Custom date format"); + fireEvent.change(customField, { target: { value: "dd.MM.yyyy" } }); + expect(screen.getByLabelText("Date format")).toHaveValue("__custom__"); + + fireEvent.change(screen.getByLabelText("Date format"), { target: { value: "MM/dd/yyyy" } }); + expect(screen.queryByLabelText("Custom date format")).not.toBeInTheDocument(); + + await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Save settings" })); }); + const [body] = mockUpdate.mock.calls[0]; + expect(body.dateFormatOverride).toBe("MM/dd/yyyy"); + }); + it("uppercases the currency and trims the text fields", async () => { mockUpdate.mockResolvedValue(undefined); await renderReady(); diff --git a/web/src/routes/SettingsPage.tsx b/web/src/routes/SettingsPage.tsx index d47f55f6e..46616abfc 100644 --- a/web/src/routes/SettingsPage.tsx +++ b/web/src/routes/SettingsPage.tsx @@ -51,6 +51,24 @@ const WEEKDAYS = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", ]; +// #452 — common date/time format presets, offered as a dropdown so most +// people never need to know .NET custom-format-string syntax (dd vs MM vs +// yyyy, which one is the day and which is the month). "Custom…" reveals the +// pre-existing free-text field for anything not listed — the server +// validates whatever ends up there either way +// (UpdateFarmSettingsValidator.BeAUsableFormat), this dropdown only changes +// how most people arrive at a value, not what's accepted. +const DATE_FORMAT_PRESETS = ["MM/dd/yyyy", "dd/MM/yyyy", "yyyy-MM-dd"]; +const TIME_FORMAT_PRESETS = ["h:mm tt", "HH:mm"]; +const CUSTOM_FORMAT_OPTION = "__custom__"; + +// A stored value that isn't blank and isn't one of the offered presets means +// the farm is already using a custom format — the dropdown must open on +// "Custom…" (with the real value in the revealed field) rather than +// silently falling back to "Follow the locale" and discarding it on save. +const isCustomFormat = (value: string, presets: string[]) => + value !== "" && !presets.includes(value); + // The browser's own IANA list, so the field offers exactly the zones this // browser can also FORMAT with — the same table todayIso() reads. The server // keeps its own (newer or older) list and remains the authority; a zone typed @@ -140,7 +158,15 @@ export function SettingsPage() { const [stepperUnits, setStepperUnits] = useState([]); const [firstDayOfWeek, setFirstDayOfWeek] = useState(""); const [dateFormat, setDateFormat] = useState(""); + // #452 — true once the user (or the loaded value) is on the "Custom…" + // branch of the dropdown, revealing the free-text field below it. Kept + // separate from `dateFormat` itself: deriving this purely from the value + // would collapse straight back to "Follow the locale" the instant someone + // picks "Custom…" from an empty/follow-locale start, since the value + // hasn't changed yet — the field would never actually appear. + const [dateFormatCustom, setDateFormatCustom] = useState(false); const [timeFormat, setTimeFormat] = useState(""); + const [timeFormatCustom, setTimeFormatCustom] = useState(false); // #236 — ONE flight for the whole screen (the old `saving` + `logoBusy` // pair, consolidated). The cross-checks those two states enforced — no save @@ -198,13 +224,17 @@ export function SettingsPage() { setCurrencyCode(s.currencyCode); setUnitSystem(s.unitSystem); setFirstDayOfWeek(s.firstDayOfWeek ?? ""); - setDateFormat(s.dateFormatOverride ?? ""); + const nextDateFormat = s.dateFormatOverride ?? ""; + setDateFormat(nextDateFormat); + setDateFormatCustom(isCustomFormat(nextDateFormat, DATE_FORMAT_PRESETS)); // A palette can be retired while farms still reference it. Echoing the // stored id straight back on the next save would 422; showing the default // selected means the next save writes a curated value, which is the // recovery the design describes. setBrand(isBrand(s.brand) ? s.brand : DEFAULT_BRAND); - setTimeFormat(s.timeFormatOverride ?? ""); + const nextTimeFormat = s.timeFormatOverride ?? ""; + setTimeFormat(nextTimeFormat); + setTimeFormatCustom(isCustomFormat(nextTimeFormat, TIME_FORMAT_PRESETS)); // Same recovery as brand: a unit can be deactivated (Products screen) // while still named as the farm default. Individual is always active and // cannot be deactivated (EggUnitConversion.cs), so it is always a safe @@ -568,14 +598,46 @@ export function SettingsPage() { + {dateFormatCustom && ( + + )} + {timeFormatCustom && ( + + )}
    {/* Disabled while a logo write is in flight too (`busy` covers every