Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
"<strong>Date format</strong> and <strong>time format</strong> offer a few common choices in a "
+ "dropdown — pick one and you are done. Need something not listed? Choose "
+ "<strong>Custom…</strong> 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 <strong>square</strong> 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, "
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion web/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
"<strong>Formato de fecha</strong> y <strong>formato de hora</strong> ofrecen algunas opciones "
+ "comunes en un menú desplegable — elija una y listo. ¿Necesita algo que no está en la lista? "
+ "Elija <strong>Personalizado…</strong> 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 <strong>cuadrado</strong>. 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 "
Expand Down Expand Up @@ -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.",

Expand Down
13 changes: 12 additions & 1 deletion web/src/i18n/tl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 <strong>format ng petsa</strong> at <strong>format ng oras</strong> ng ilang karaniwang "
+ "pagpipilian sa isang dropdown — pumili at tapos na. Kailangan ng hindi nakalista? Piliin ang "
+ "<strong>Custom…</strong> 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 <strong>parisukat</strong> 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 "
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions web/src/routes/HelpPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,10 @@ export function HelpPage() {
<li>
<Trans ns="help" i18nKey="farmSettingsCurrency" components={{ strong: <strong /> }} />
</li>
{/* #452 — preset dropdown + Custom escape hatch for date/time format overrides. */}
<li>
<Trans ns="help" i18nKey="farmSettingsDateTimeFormat" components={{ strong: <strong /> }} />
</li>
<li>
<Trans ns="help" i18nKey="farmSettingsLogo" components={{ strong: <strong /> }} />
</li>
Expand Down
64 changes: 63 additions & 1 deletion web/src/routes/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
Expand Down Expand Up @@ -171,14 +197,50 @@ 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];
expect(body.firstDayOfWeek).toBeNull();
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();
Expand Down
74 changes: 68 additions & 6 deletions web/src/routes/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Comment thread
mforce marked this conversation as resolved.
Resolved
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
Expand Down Expand Up @@ -140,7 +158,15 @@ export function SettingsPage() {
const [stepperUnits, setStepperUnits] = useState<EggUnitConversion[]>([]);
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -568,14 +598,46 @@ export function SettingsPage() {
</fieldset>

<label>{t("dateFormatLabel")}
<input value={dateFormat} maxLength={MAX_FORMAT} placeholder={t("followLocaleOption")}
onChange={(e) => setDateFormat(e.target.value)} />
<select
value={dateFormatCustom ? CUSTOM_FORMAT_OPTION : dateFormat}
onChange={(e) => {
if (e.target.value === CUSTOM_FORMAT_OPTION) { setDateFormatCustom(true); return; }
setDateFormatCustom(false);
setDateFormat(e.target.value);
}}
>
<option value="">{t("followLocaleOption")}</option>
{DATE_FORMAT_PRESETS.map((p) => <option key={p} value={p}>{p}</option>)}
<option value={CUSTOM_FORMAT_OPTION}>{t("customFormatOption")}</option>
</select>
</label>
{dateFormatCustom && (
<label>{t("customDateFormatLabel")}
<input value={dateFormat} maxLength={MAX_FORMAT}
onChange={(e) => setDateFormat(e.target.value)} />
</label>
)}

<label>{t("timeFormatLabel")}
<input value={timeFormat} maxLength={MAX_FORMAT} placeholder={t("followLocaleOption")}
onChange={(e) => setTimeFormat(e.target.value)} />
<select
value={timeFormatCustom ? CUSTOM_FORMAT_OPTION : timeFormat}
onChange={(e) => {
if (e.target.value === CUSTOM_FORMAT_OPTION) { setTimeFormatCustom(true); return; }
setTimeFormatCustom(false);
setTimeFormat(e.target.value);
}}
>
<option value="">{t("followLocaleOption")}</option>
{TIME_FORMAT_PRESETS.map((p) => <option key={p} value={p}>{p}</option>)}
<option value={CUSTOM_FORMAT_OPTION}>{t("customFormatOption")}</option>
</select>
</label>
{timeFormatCustom && (
<label>{t("customTimeFormatLabel")}
<input value={timeFormat} maxLength={MAX_FORMAT}
onChange={(e) => setTimeFormat(e.target.value)} />
</label>
)}

<div className="actions">
{/* Disabled while a logo write is in flight too (`busy` covers every
Expand Down
Loading