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