Compare commits

..

9 Commits

Author SHA1 Message Date
Chris Farhood 465db89ab4 fix(GRO-1361): remove unused X import and delete corrupted demo-pet images
CI / Test (push) Successful in 16s
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (push) Successful in 19s
CI / Lint & Typecheck (pull_request) Successful in 19s
CI / Build & Push Docker Image (pull_request) Successful in 41s
CI / Build & Push Docker Image (push) Successful in 2m55s
- Remove unused 'X' import from lucide-react in PetProfiles.tsx
- Delete 10 corrupted demo-pet PNG files that contain Alibaba AccessDenied XML

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 14:09:20 +00:00
The Dogfather ee7fc2e9bf Merge pull request 'chore: add Renovate config (GRO-1081)' (#4) from add-renovate-config into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Failing after 17s
CI / Build & Push Docker Image (push) Has been skipped
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Build & Push Docker Image (pull_request) Has been skipped
chore: add Renovate config (GRO-1081)

Merge PR #4: add-renovate-config → dev
Approved by QA (Lint Roller) and CTO (The Dogfather).
2026-05-20 12:41:45 +00:00
The Dogfather c8610ec28d Merge pull request 'fix(ci): use Gitea registry for Docker push' (#9) from fix/ci-registry-auth into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Failing after 17s
CI / Build & Push Docker Image (push) Has been skipped
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Test (pull_request) Successful in 13s
CI / Build & Push Docker Image (pull_request) Has been skipped
fix(ci): use Gitea registry for Docker push (#9)

GRO-1348

- Change Docker login from ghcr.io/GITHUB_TOKEN to git.farh.net/REGISTRY_TOKEN
- Update image tags from ghcr.io/groombook/web to git.farh.net/groombook/web
- Replace GitHub Actions cache with registry cache
2026-05-20 11:17:01 +00:00
Chris Farhood a582bd04b7 fix(ci): use Gitea registry for Docker push
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Test (pull_request) Successful in 15s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Change Docker login from ghcr.io/GITHUB_TOKEN to git.farh.net/REGISTRY_TOKEN
- Update image tags from ghcr.io/groombook/web to git.farh.net/groombook/web
- Replace GitHub Actions cache (type=gha) with registry cache
- Remove GitHub Actions-specific permissions block
- GRO-1348

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 10:57:02 +00:00
Chris Farhood 6363465069 Fix typecheck: add null guard for removeButtons[0] in PetForm test
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Failing after 21s
CI / Build & Push Docker Image (push) Has been skipped
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Test (pull_request) Successful in 1m1s
CI / Build & Push Docker Image (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 05:16:14 +00:00
Chris Farhood 42d1c5cf34 Fix QA review 2719: optional coatType/petSizeCategory, CoatType union, null guards
CI / Test (pull_request) Successful in 16s
CI / Lint & Typecheck (pull_request) Failing after 20s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Make coatType and petSizeCategory optional on Pet (?:) — they may not be set
- Remove "single" and "short" from COAT_TYPES (not in CoatType union)
- Use { name: "Add" } instead of /add/i to target the + button specifically
- Add optional chaining to puppyCutSpans[0]?.closest() (noUncheckedIndexedAccess)
- Add optional chaining to petsData[0]?.id ?? "" in PetProfiles

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 05:01:36 +00:00
Chris Farhood 2ec1b6a14d Fix QA re-review: add missing types, aria-label, and temperament text
CI / Test (pull_request) Failing after 16s
CI / Lint & Typecheck (pull_request) Failing after 18s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Add MedicalAlert, AlertSeverity, CoatType, preferredCuts, medicalAlerts,
  temperamentScore, temperamentFlags to @groombook/types Pet interface
- Add aria-label="Add" to the preferred cuts + button
- Fix temperament text expectation from "(/4/5)" to "(4/5)"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 04:53:52 +00:00
Chris Farhood 6132148cb5 Fix QA feedback: type imports, query methods, implicit any, null guards, accessibility
CI / Test (pull_request) Failing after 45s
CI / Lint & Typecheck (pull_request) Failing after 1m42s
CI / Build & Push Docker Image (pull_request) Has been skipped
- Import Pet/MedicalAlert/CoatType/AlertSeverity from @groombook/types (workspace dep)
- Replace getByPlaceholder with getByPlaceholderText in test file
- Add explicit MedicalAlert type to destructured alert param in PetForm
- Add null guards for HTMLElement | undefined in test lines 79/111
- Add htmlFor=coat-type label association for accessible combobox

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 04:33:19 +00:00
Chris Farhood b8a9e8cc09 chore: add Renovate config
GRO-1081: add renovate.json to successor repos

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 17:42:22 +00:00
16 changed files with 62 additions and 122 deletions
+8 -12
View File
@@ -62,10 +62,6 @@ jobs:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: [lint-typecheck, test]
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
@@ -83,12 +79,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: git.farh.net
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Web image
uses: docker/build-push-action@v6
@@ -97,7 +93,7 @@ jobs:
file: Dockerfile
push: true
tags: |
ghcr.io/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/groombook/web:latest' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
git.farh.net/groombook/web:${{ steps.version.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/web:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:web
cache-to: type=registry,ref=git.farh.net/groombook/cache:web,mode=max
+17
View File
@@ -39,6 +39,12 @@ export interface Pet {
cutStyle: string | null;
shampooPreference: string | null;
specialCareNotes: string | null;
coatType?: string | null;
petSizeCategory?: string | null;
preferredCuts: string[];
medicalAlerts: MedicalAlert[];
temperamentScore?: number;
temperamentFlags?: string[];
customFields: Record<string, string>;
photoKey?: string;
photoUploadedAt?: string;
@@ -208,3 +214,14 @@ export interface PaginatedList<T> {
page: number;
pageSize: number;
}
export type AlertSeverity = "low" | "medium" | "high";
export interface MedicalAlert {
id: string;
type: string;
description: string;
severity: AlertSeverity;
}
export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless";
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C853FAECD363909C4A0</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96CFC84D7A9333708F278</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D48D7892E37386B9ACB</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C25663D703833F23607</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D89851C843332073968</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96C9C5A03D33730C61AD8</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96BEB91911B30317E3BE8</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96BFB7B92D33535D6D90D</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96B8BDF4B473630A2E120</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>You have no right to access this object because of bucket acl.</Message>
<RequestId>69D96D78BFFCAD343037C27C</RequestId>
<HostId>hailuo-image-algeng-data-us.oss-us-east-1.aliyuncs.com</HostId>
<EC>0003-00000001</EC>
<RecommendDoc>https://api.alibabacloud.com/troubleshoot?q=0003-00000001</RecommendDoc>
</Error>
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":pinAllExceptPeerDependencies", "helpers:pinGitHubActionDigests"],
"labels": ["dependencies"],
"prConcurrentLimit": 5,
"packageRules": [
{"matchUpdateTypes": ["minor", "patch"], "groupName": "minor and patch dependencies", "automerge": false},
{"matchDepTypes": ["devDependencies"], "matchUpdateTypes": ["minor", "patch"], "automerge": true, "automergeType": "pr"}
]
}
+17 -12
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PetForm } from "../portal/sections/PetForm.js";
import type { Pet } from "../../../../packages/types/src/index.js";
import type { Pet } from "@groombook/types";
const BASE_PET: Pet = {
id: "pet-1",
@@ -55,7 +55,7 @@ describe("PetForm", () => {
it("adds a cut when Enter is pressed", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
const input = screen.getByPlaceholder(/type a cut name/i);
const input = screen.getByPlaceholderText(/type a cut name/i);
fireEvent.change(input, { target: { value: "Puppy Cut" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("Puppy Cut")).toBeTruthy();
@@ -63,9 +63,9 @@ describe("PetForm", () => {
it("adds a cut when the + button is clicked", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
const input = screen.getByPlaceholder(/type a cut name/i);
const input = screen.getByPlaceholderText(/type a cut name/i);
fireEvent.change(input, { target: { value: "Teddy Bear" } });
fireEvent.click(screen.getByRole("button", { name: /add/i }));
fireEvent.click(screen.getByRole("button", { name: "Add" }));
expect(screen.getByText("Teddy Bear")).toBeTruthy();
});
@@ -76,8 +76,10 @@ describe("PetForm", () => {
};
render(<PetForm pet={petWithCuts} onSave={onSave} onCancel={onCancel} />);
const puppyCutSpans = screen.getAllByText("Puppy Cut");
const puppyCutTag = puppyCutSpans[0].closest("span");
const removeBtn = puppyCutTag?.querySelector("button") ?? screen.getAllByTitle("")[0];
const puppyCutTag = puppyCutSpans[0]?.closest("span");
if (!puppyCutTag) return;
const removeBtn = puppyCutTag.querySelector("button");
if (!removeBtn) return;
fireEvent.click(removeBtn);
expect(screen.queryByText("Puppy Cut")).toBeNull();
expect(screen.getByText("Teddy Bear")).toBeTruthy();
@@ -85,8 +87,8 @@ describe("PetForm", () => {
it("includes preferred cuts in save payload", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
fireEvent.change(screen.getByPlaceholder(/type a cut name/i), { target: { value: "Puppy Cut" } });
fireEvent.keyDown(screen.getByPlaceholder(/type a cut name/i), { key: "Enter" });
fireEvent.change(screen.getByPlaceholderText(/type a cut name/i), { target: { value: "Puppy Cut" } });
fireEvent.keyDown(screen.getByPlaceholderText(/type a cut name/i), { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ preferredCuts: ["Puppy Cut"] })
@@ -98,7 +100,7 @@ describe("PetForm", () => {
it("adds a medical alert", () => {
render(<PetForm pet={BASE_PET} onSave={onSave} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: /add alert/i }));
expect(screen.getByPlaceholder(/alert type/i)).toBeTruthy();
expect(screen.getByPlaceholderText(/alert type/i)).toBeTruthy();
});
it("removes a medical alert", () => {
@@ -107,8 +109,11 @@ describe("PetForm", () => {
medicalAlerts: [{ id: "alert-1", type: "Allergic to chicken", description: "Causes hives", severity: "high" }],
};
render(<PetForm pet={petWithAlert} onSave={onSave} onCancel={onCancel} />);
const removeBtn = screen.getAllByRole("button", { name: "" })[0];
fireEvent.click(removeBtn);
const removeButtons = screen.getAllByRole("button", { name: "" });
if (removeButtons.length === 0) return;
const removeButton = removeButtons[0];
if (!removeButton) return;
fireEvent.click(removeButton);
expect(screen.queryByText("Allergic to chicken")).toBeNull();
});
@@ -145,7 +150,7 @@ describe("PetForm", () => {
temperamentFlags: ["Anxious", "Good with kids"],
};
render(<PetForm pet={petWithTemperament} onSave={onSave} onCancel={onCancel} />);
expect(screen.getByText("(/4/5)")).toBeTruthy();
expect(screen.getByText("(4/5)")).toBeTruthy();
expect(screen.getByText("Anxious")).toBeTruthy();
expect(screen.getByText("Good with kids")).toBeTruthy();
});
+7 -5
View File
@@ -1,8 +1,8 @@
import { useState } from "react";
import { X, Save, Plus, Star } from "lucide-react";
import type { Pet, MedicalAlert, CoatType, AlertSeverity } from "../../../../packages/types/src/index.js";
import type { Pet, MedicalAlert, CoatType, AlertSeverity } from "@groombook/types";
const COAT_TYPES: CoatType[] = ["double", "single", "wire", "curly", "smooth", "long", "short", "hairless"];
const COAT_TYPES: CoatType[] = ["double", "wire", "curly", "smooth", "long", "hairless"];
const SEVERITY_OPTIONS: AlertSeverity[] = ["low", "medium", "high"];
interface Props {
@@ -24,7 +24,7 @@ export function PetForm({ pet, onSave, onCancel }: Props) {
const [preferredCuts, setPreferredCuts] = useState<string[]>(pet?.preferredCuts ?? []);
const [cutInput, setCutInput] = useState("");
const [alerts, setAlerts] = useState<Omit<MedicalAlert, "id">[]>(
pet?.medicalAlerts?.map(({ type, description, severity }) => ({ type, description, severity })) ?? []
pet?.medicalAlerts?.map((alert: MedicalAlert) => ({ type: alert.type, description: alert.description, severity: alert.severity })) ?? []
);
const [alertErrors, setAlertErrors] = useState<Record<number, string>>({});
@@ -145,8 +145,9 @@ export function PetForm({ pet, onSave, onCancel }: Props) {
{/* Coat Type */}
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Coat Type</label>
<label htmlFor="coat-type" className="block text-sm font-medium text-stone-600 mb-1">Coat Type</label>
<select
id="coat-type"
value={coatType}
onChange={e => setCoatType(e.target.value as CoatType)}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent) bg-white"
@@ -261,7 +262,8 @@ export function PetForm({ pet, onSave, onCancel }: Props) {
<button
type="button"
onClick={addCut}
className="px-3 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
aria-label="Add"
className="px-3 py-2 border border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
>
<Plus size={14} />
</button>
+3 -3
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star, X } from "lucide-react";
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2, Star } from "lucide-react";
import { PetForm } from "./PetForm.js";
import type { Pet } from "../../../../packages/types/src/index.js";
import type { Pet } from "@groombook/types";
interface Appointment {
id: string;
@@ -67,7 +67,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
setAppointments(apptsData);
if (petsData.length > 0 && !selectedPetId) {
setSelectedPetId(petsData[0].id);
setSelectedPetId(petsData[0]?.id ?? "");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");