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
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning
[backend/tests/**.cs]
dotnet_naming_rule.types_and_members_are_pascal_case.severity = none
dotnet_diagnostic.CA1707.severity = none

# EF Core migrations are generated by `dotnet ef`; they are not reformatted to match the C# style above.
[backend/src/PureFreshMilk.Infrastructure/Persistence/Migrations/*.cs]
generated_code = true
36 changes: 30 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ jobs:
- run: dotnet format PureFreshMilk.slnx --verify-no-changes --no-restore
# Directory.Build.props sets TreatWarningsAsErrors, so warnings fail the build.
- run: dotnet build PureFreshMilk.slnx --configuration Release --no-restore
- run: dotnet test --solution PureFreshMilk.slnx --configuration Release --no-build
# Tests that need PostgreSQL are tagged Category=Database and run in the database job. The test
# project that holds only those is left with no tests, which ends with exit code 8 ("zero tests ran").
- run: dotnet test --solution PureFreshMilk.slnx --configuration Release --no-build --filter-not-trait "Category=Database" --ignore-exit-code 8

database:
name: Database — Compose PostgreSQL + API readiness
Expand All @@ -92,24 +94,46 @@ jobs:
working-directory: backend
run: dotnet build src/PureFreshMilk.Api --configuration Release

- name: Start the API and wait until /health/ready is healthy
- name: Apply the migrations to the empty database
working-directory: backend
env:
ConnectionStrings__PureFreshMilk: Host=localhost;Port=5432;Database=purefreshmilk;Username=purefreshmilk;Password=${{ env.POSTGRES_PASSWORD }}
run: |
dotnet tool restore
dotnet ef database update --project src/PureFreshMilk.Infrastructure --startup-project src/PureFreshMilk.Api --configuration Release --no-build

- name: Database tests (a throwaway database per test class, built from the migrations)
working-directory: backend
env:
ConnectionStrings__PureFreshMilkTests: Host=localhost;Port=5432;Database=purefreshmilk;Username=purefreshmilk;Password=${{ env.POSTGRES_PASSWORD }}
run: dotnet test --project tests/PureFreshMilk.Infrastructure.Tests --configuration Release

- name: Start the API, wait until /health/ready is healthy, then smoke-test the data endpoints
working-directory: backend
env:
ConnectionStrings__PureFreshMilk: Host=localhost;Port=5432;Database=purefreshmilk;Username=purefreshmilk;Password=${{ env.POSTGRES_PASSWORD }}
ASPNETCORE_URLS: http://127.0.0.1:5080
# The data endpoints exist outside Development only when switched on, until authentication exists.
Access__AllowUnauthenticated: "true"
run: |
dotnet run --project src/PureFreshMilk.Api --configuration Release --no-build --no-launch-profile > api.log 2>&1 &
ready=false
# Ready also means every migration has been applied.
for attempt in $(seq 1 30); do
if curl --fail --silent http://127.0.0.1:5080/health/ready; then
echo
echo "API is ready."
exit 0
ready=true
break
fi
sleep 2
done
echo "The API did not become ready within 60 seconds."
cat api.log
exit 1
if [ "$ready" != true ]; then
echo "The API did not become ready within 60 seconds."
cat api.log
exit 1
fi
bash ../scripts/smoke-api.sh http://127.0.0.1:5080 || { cat api.log; exit 1; }

- name: Stop PostgreSQL
if: always()
Expand Down
3 changes: 2 additions & 1 deletion apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copy to .env.local (git-ignored) and adjust. Placeholders only — never commit real values.

# Base URL of the ASP.NET Core API, without a trailing slash or /api/v1.
# Optional: the web app works without the API (the Today screen uses demo data).
# Optional: the web app works without the API. Then Today and Recipients show labelled demo data, and
# Settings says it needs the API. With an address, Recipients and Settings use the API's data.
# NEXT_PUBLIC_* values are inlined into the browser bundle at build time, so never put secrets here.
NEXT_PUBLIC_API_BASE_URL=http://localhost:5080
4 changes: 4 additions & 0 deletions apps/web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export default defineConfig({
command: isCI
? `npm run start -- --port ${port}`
: `npm run build && npm run start -- --port ${port}`,
// These tests cover the app with no API address, where the Recipients and Settings screens say so
// and show labelled demo data. Pin it, so a developer's .env.local cannot change what is tested.
// (CI builds earlier, in a clean checkout.)
env: { NEXT_PUBLIC_API_BASE_URL: "" },
url: baseURL,
reuseExistingServer: !isCI,
timeout: 240_000,
Expand Down
23 changes: 2 additions & 21 deletions apps/web/src/app/app/recipients/page.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,8 @@
import type { Metadata } from "next";
import { PageHeader } from "@/components/layout/page-header";
import { DemoBadge } from "@/components/ui/badge";
import { Notice } from "@/components/ui/notice";
import { DemoRecipientList } from "@/features/recipients/demo-recipient-list";
import { DEMO_RECIPIENTS } from "@/lib/demo-household";
import { RecipientsScreen } from "@/features/recipients/recipients-screen";

export const metadata: Metadata = { title: "Recipients" };

export default function RecipientsPage() {
return (
<>
<PageHeader
title="Recipients"
description="Everyone in the household who gets a daily delivery, with their usual quantity."
actions={<DemoBadge />}
/>
<div className="grid gap-4">
<DemoRecipientList recipients={DEMO_RECIPIENTS} />
<Notice title="Adding and editing recipients isn't built yet">
These are the demo household&apos;s recipients. Managing your own arrives once data is
saved.
</Notice>
</div>
</>
);
return <RecipientsScreen />;
}
16 changes: 7 additions & 9 deletions apps/web/src/app/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Metadata } from "next";
import { House } from "lucide-react";
import { CalendarOff } from "lucide-react";
import { PageHeader } from "@/components/layout/page-header";
import { PlaceholderPanel } from "@/components/ui/placeholder-panel";
import { ApiStatusCard } from "@/features/settings/api-status-card";
import { HouseholdSettings } from "@/features/settings/household-settings";

export const metadata: Metadata = { title: "Settings" };

Expand All @@ -11,15 +12,12 @@ export default function SettingsPage() {
<>
<PageHeader title="Settings" />
<div className="grid gap-4">
<HouseholdSettings />
<PlaceholderPanel
icon={House}
title="Household settings aren't built yet"
description="This is where your household's details and milk prices will be managed."
planned={[
"Household name and members",
"Milk price per litre",
"Delivery days and pauses",
]}
icon={CalendarOff}
title="Delivery days and pauses aren't built yet"
description="This is where days without deliveries, such as a trip away, will be managed."
planned={["Delivery days and pauses"]}
/>
<ApiStatusCard />
</div>
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/components/ui/select-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ComponentProps } from "react";
import { cn } from "@/lib/cn";

interface SelectFieldProps extends Omit<ComponentProps<"select">, "id"> {
id: string;
label: string;
hint?: string;
/** What is wrong with the value. Shown under the field and announced with it. */
error?: string;
options: readonly { value: string; label: string }[];
}

export function SelectField({
id,
label,
hint,
error,
options,
className,
...selectProps
}: SelectFieldProps) {
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;

return (
<div className={cn("grid gap-1.5", className)}>
<label htmlFor={id} className="text-sm font-semibold text-ink">
{label}
</label>
<select
id={id}
aria-describedby={[hintId, errorId].filter(Boolean).join(" ") || undefined}
aria-invalid={error ? true : undefined}
className={cn(
"min-h-12 w-full rounded-xl border bg-surface px-3 text-base text-ink",
error ? "border-missed" : "border-line-strong",
"focus-visible:border-accent disabled:cursor-not-allowed disabled:bg-canvas disabled:text-ink-muted",
)}
{...selectProps}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{hint ? (
<p id={hintId} className="text-sm text-ink-muted">
{hint}
</p>
) : null}
{error ? (
<p id={errorId} className="text-sm font-semibold text-missed-ink">
{error}
</p>
) : null}
</div>
);
}
16 changes: 13 additions & 3 deletions apps/web/src/components/ui/text-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ interface TextFieldProps extends Omit<ComponentProps<"input">, "id"> {
id: string;
label: string;
hint?: string;
/** What is wrong with the value. Shown under the field and announced with it. */
error?: string;
}

export function TextField({ id, label, hint, className, ...inputProps }: TextFieldProps) {
export function TextField({ id, label, hint, error, className, ...inputProps }: TextFieldProps) {
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;

return (
<div className={cn("grid gap-1.5", className)}>
Expand All @@ -17,9 +20,11 @@ export function TextField({ id, label, hint, className, ...inputProps }: TextFie
</label>
<input
id={id}
aria-describedby={hintId}
aria-describedby={[hintId, errorId].filter(Boolean).join(" ") || undefined}
aria-invalid={error ? true : undefined}
className={cn(
"min-h-12 w-full rounded-xl border border-line-strong bg-surface px-3.5 text-base text-ink",
"min-h-12 w-full rounded-xl border bg-surface px-3.5 text-base text-ink",
error ? "border-missed" : "border-line-strong",
"placeholder:text-ink-muted focus-visible:border-accent",
"disabled:cursor-not-allowed disabled:bg-canvas disabled:text-ink-muted",
)}
Expand All @@ -30,6 +35,11 @@ export function TextField({ id, label, hint, className, ...inputProps }: TextFie
{hint}
</p>
) : null}
{error ? (
<p id={errorId} className="text-sm font-semibold text-missed-ink">
{error}
</p>
) : null}
</div>
);
}
85 changes: 85 additions & 0 deletions apps/web/src/features/household/household-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use client";

import { useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { SelectField } from "@/components/ui/select-field";
import { TextField } from "@/components/ui/text-field";
import { describeApiError } from "@/features/shared/api-error-message";
import { DEFAULT_TIME_ZONE_ID, timeZoneOptions } from "@/lib/time-zones";
import { fieldErrors } from "@/services/api-client";
import { setUpHousehold } from "@/services/household-service";
import type { ApiHousehold } from "@/types/api";

/**
* Creates the household (first run) or changes its name and time zone. The household has to exist before
* anything else can be saved, because everything belongs to it.
*/
export function HouseholdForm({
household,
submitLabel,
onSaved,
}: {
/** The existing household, or undefined when it is being created. */
household?: ApiHousehold;
submitLabel: string;
onSaved: (household: ApiHousehold) => void;
}) {
const [name, setName] = useState(household?.name ?? "");
const [timeZoneId, setTimeZoneId] = useState(household?.timeZoneId ?? DEFAULT_TIME_ZONE_ID);
const [errors, setErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);

async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
setErrors({});
setFormError(null);

try {
onSaved(await setUpHousehold({ name, timeZoneId }));
} catch (error) {
const fields = fieldErrors(error);
if (Object.keys(fields).length > 0) setErrors(fields);
else setFormError(describeApiError(error));
} finally {
setSaving(false);
}
}

return (
<form noValidate onSubmit={submit} className="grid gap-4">
<TextField
id="household-name"
label="Household name"
hint="Only you see this, for example “Our home”."
autoComplete="off"
value={name}
onChange={(event) => setName(event.target.value)}
error={errors.name}
/>
<SelectField
id="household-time-zone"
label="Time zone"
hint="Decides what “today” means. Deliveries are recorded by calendar day in this time zone."
value={timeZoneId}
onChange={(event) => setTimeZoneId(event.target.value)}
options={timeZoneOptions(household?.timeZoneId).map((zone) => ({
value: zone.id,
label: zone.label,
}))}
error={errors.timeZoneId}
/>
{formError ? (
<p role="alert" className="text-sm font-semibold text-missed-ink">
{formError}
</p>
) : null}
<div>
<Button type="submit" disabled={saving}>
{saving ? "Saving…" : submitLabel}
</Button>
</div>
</form>
);
}
Loading
Loading