Compare commits
No files matched your search
@@ -107,4 +107,6 @@ jobs:
|
|||||||
run: npx prisma migrate deploy
|
run: npx prisma migrate deploy
|
||||||
|
|
||||||
- name: E2E-Tests
|
- name: E2E-Tests
|
||||||
run: npm run test:e2e -- --ci
|
# Auf dem geteilten Runner brauchten App-Start plus bcrypt-Logins in
|
||||||
|
# beforeAll mehr als die lokalen 20 s (Laeufe 826, 836); hier 60 s.
|
||||||
|
run: npm run test:e2e -- --ci --testTimeout=60000
|
||||||
@@ -105,6 +105,7 @@
|
|||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node",
|
||||||
|
"testTimeout": 20000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,17 @@ describe('loadLots', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves out a lot whose medicine vanished between the two reads', async () => {
|
||||||
|
const { db } = fakeDb(
|
||||||
|
[lotRow({ id: 'l1' }), lotRow({ id: 'gone', medicine: null })],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { lots } = await loadLots(db);
|
||||||
|
|
||||||
|
expect(lots.map((lot) => lot.id)).toEqual(['l1']);
|
||||||
|
});
|
||||||
|
|
||||||
it('reads only active medicines', async () => {
|
it('reads only active medicines', async () => {
|
||||||
const { db } = fakeDb(
|
const { db } = fakeDb(
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -57,12 +57,16 @@ export async function loadLots(db: DbClient): Promise<ReadinessLots> {
|
|||||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
// Prisma reads the medicine in a second query: a lot deleted together with
|
||||||
|
// its medicine in between comes back with medicine null (CI run 836, 500).
|
||||||
|
// Such a lot is gone, so it is left out.
|
||||||
|
const present = rows.filter((row) => (row.medicine as unknown) !== null);
|
||||||
const totals = await lotTotals(
|
const totals = await lotTotals(
|
||||||
db,
|
db,
|
||||||
rows.map((row) => row.id),
|
present.map((row) => row.id),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
lots: rows.map((row): ReadinessLot => ({
|
lots: present.map((row): ReadinessLot => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
medicineId: row.medicineId,
|
medicineId: row.medicineId,
|
||||||
medicineName: row.medicine.name,
|
medicineName: row.medicine.name,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { formatReport } from './eval-report';
|
import { formatReport } from './eval-report';
|
||||||
import { loadGoldCases, parseGoldCase, type GoldCase } from './gold-case';
|
import { loadGoldCases, parseGoldCase, type GoldCase } from './gold-case';
|
||||||
@@ -317,6 +317,10 @@ describe('eval:voice stays out of CI', () => {
|
|||||||
'.gitea',
|
'.gitea',
|
||||||
'workflows',
|
'workflows',
|
||||||
);
|
);
|
||||||
|
// The backend image builds without the repo root: nothing to check there.
|
||||||
|
if (!existsSync(workflows)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const files = readdirSync(workflows).filter((name) =>
|
const files = readdirSync(workflows).filter((name) =>
|
||||||
/\.ya?ml$/.test(name),
|
/\.ya?ml$/.test(name),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ const NOTHING_NEW = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The job evaluates the whole database. The e2e files share it and run in
|
* The job evaluates the whole database, and the expiry step expires EVERY open
|
||||||
* parallel, so the evaluation runs on days in 2011 and 2012: no other file has
|
* suggestion whose expiresAt lies before the given day. The e2e files share
|
||||||
* records there (autopilot and reminders own 2001; this file used to share it,
|
* the database and run in parallel, so this file runs on days in 1991 and
|
||||||
* so its "nothing new" counts picked up their colonies and its phenology step
|
* 1992: earlier than any date any other file uses (autopilot and reminders
|
||||||
* wrote season rows for their apiaries), and the events of this file stay its own. Called through
|
* use 2001, others 2002 and later). A later year would let the expiry step
|
||||||
|
* expire their open suggestions (and count them in our totals), and an
|
||||||
|
* "own" year in between would still see 2001's records. Called through
|
||||||
* TimeEventsStep.run(day) instead of runCli, which would also write JobRun
|
* TimeEventsStep.run(day) instead of runCli, which would also write JobRun
|
||||||
* rows that test/jobs.e2e-spec.ts counts.
|
* rows that test/jobs.e2e-spec.ts counts.
|
||||||
*/
|
*/
|
||||||
@@ -68,7 +70,7 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
phenologyStep = app.get(PhenologyStep);
|
phenologyStep = app.get(PhenologyStep);
|
||||||
expiryStep = app.get(SuggestionsExpiryStep);
|
expiryStep = app.get(SuggestionsExpiryStep);
|
||||||
// The real AutopilotStep, but on the colonies of this file only: other
|
// The real AutopilotStep, but on the colonies of this file only: other
|
||||||
// files share the database and must not be reconciled on a 2011 day.
|
// files share the database and must not be reconciled on a 1991 day.
|
||||||
autopilotStep = new AutopilotStep(
|
autopilotStep = new AutopilotStep(
|
||||||
{
|
{
|
||||||
colony: {
|
colony: {
|
||||||
@@ -109,18 +111,18 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
ids[`queen:${name}`] = created.id;
|
ids[`queen:${name}`] = created.id;
|
||||||
};
|
};
|
||||||
await queen('aged', { hatchYear: 2009, status: 'ACTIVE' });
|
await queen('aged', { hatchYear: 1989, status: 'ACTIVE' });
|
||||||
await queen('young', { hatchYear: 2010, status: 'ACTIVE' });
|
await queen('young', { hatchYear: 1990, status: 'ACTIVE' });
|
||||||
await queen('reserve', { hatchYear: 2009, status: 'RESERVE' });
|
await queen('reserve', { hatchYear: 1989, status: 'RESERVE' });
|
||||||
|
|
||||||
// Living, created 2011-04-20 and never measured: varroa due 2011-05-18.
|
// Living, created 1991-04-20 and never measured: varroa due 1991-05-18.
|
||||||
const living = await prisma.colony.create({
|
const living = await prisma.colony.create({
|
||||||
data: {
|
data: {
|
||||||
code: `DE-E2E-${suffix}-living`,
|
code: `DE-E2E-${suffix}-living`,
|
||||||
apiaryId: apiary.id,
|
apiaryId: apiary.id,
|
||||||
colonyType: 'PRODUCTION_COLONY',
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
currentQueenId: ids['queen:aged'],
|
currentQueenId: ids['queen:aged'],
|
||||||
createdAt: d('2011-04-20'),
|
createdAt: d('1991-04-20'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ids.living = living.id;
|
ids.living = living.id;
|
||||||
@@ -130,15 +132,15 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
apiaryId: apiary.id,
|
apiaryId: apiary.id,
|
||||||
colonyType: 'PRODUCTION_COLONY',
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
status: 'DECEASED',
|
status: 'DECEASED',
|
||||||
createdAt: d('2011-04-20'),
|
createdAt: d('1991-04-20'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ids.dead = dead.id;
|
ids.dead = dead.id;
|
||||||
// Inspected 2011-04-28, in the swarm season (every 7 days): the next
|
// Inspected 1991-04-28, in the swarm season (every 7 days): the next
|
||||||
// inspection is due 2011-05-05 and overdue on 2011-05-06.
|
// inspection is due 1991-05-05 and overdue on 1991-05-06.
|
||||||
for (const colonyId of [living.id, dead.id]) {
|
for (const colonyId of [living.id, dead.id]) {
|
||||||
await prisma.inspection.create({
|
await prisma.inspection.create({
|
||||||
data: { colonyId, userId: user.id, date: d('2011-04-28') },
|
data: { colonyId, userId: user.id, date: d('1991-04-28') },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,18 +161,18 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
ids[`treatment:${name}`] = created.id;
|
ids[`treatment:${name}`] = created.id;
|
||||||
};
|
};
|
||||||
// 2011-05-10 + 7 = 2011-05-17, in the window of 2011-05-20.
|
// 1991-05-10 + 7 = 1991-05-17, in the window of 1991-05-20.
|
||||||
await treatment('ending', {
|
await treatment('ending', {
|
||||||
date: d('2011-05-08'),
|
date: d('1991-05-08'),
|
||||||
endDate: d('2011-05-10'),
|
endDate: d('1991-05-10'),
|
||||||
withdrawalPeriodDays: 7,
|
withdrawalPeriodDays: 7,
|
||||||
});
|
});
|
||||||
// 2011-05-01 + 7 = 2011-05-08, before the window.
|
// 1991-05-01 + 7 = 1991-05-08, before the window.
|
||||||
await treatment('ended-earlier', {
|
await treatment('ended-earlier', {
|
||||||
date: d('2011-05-01'),
|
date: d('1991-05-01'),
|
||||||
withdrawalPeriodDays: 7,
|
withdrawalPeriodDays: 7,
|
||||||
});
|
});
|
||||||
await treatment('no-withdrawal', { date: d('2011-05-15') });
|
await treatment('no-withdrawal', { date: d('1991-05-15') });
|
||||||
|
|
||||||
const task = async (
|
const task = async (
|
||||||
name: string,
|
name: string,
|
||||||
@@ -181,8 +183,8 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
ids[`task:${name}`] = created.id;
|
ids[`task:${name}`] = created.id;
|
||||||
};
|
};
|
||||||
await task('open', { dueDate: d('2011-05-18'), status: 'OPEN' });
|
await task('open', { dueDate: d('1991-05-18'), status: 'OPEN' });
|
||||||
await task('done', { dueDate: d('2011-05-18'), status: 'DONE' });
|
await task('done', { dueDate: d('1991-05-18'), status: 'DONE' });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -224,10 +226,10 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => {
|
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => {
|
||||||
const first = await step.run('2011-05-20');
|
const first = await step.run('1991-05-20');
|
||||||
const second = await step.run('2011-05-20');
|
const second = await step.run('1991-05-20');
|
||||||
// The next day the occurrence is still in the window - and still known.
|
// The next day the occurrence is still in the window - and still known.
|
||||||
const nextDay = await step.run('2011-05-21');
|
const nextDay = await step.run('1991-05-21');
|
||||||
|
|
||||||
expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1);
|
expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1);
|
||||||
expect(second).toEqual(NOTHING_NEW);
|
expect(second).toEqual(NOTHING_NEW);
|
||||||
@@ -236,8 +238,8 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
{
|
{
|
||||||
type: 'WithdrawalEnded',
|
type: 'WithdrawalEnded',
|
||||||
aggregateType: 'Treatment',
|
aggregateType: 'Treatment',
|
||||||
payload: { colonyId: ids.living, endedOn: '2011-05-17' },
|
payload: { colonyId: ids.living, endedOn: '1991-05-17' },
|
||||||
dedupeKey: `WithdrawalEnded:${ids['treatment:ending']}:2011-05-17`,
|
dedupeKey: `WithdrawalEnded:${ids['treatment:ending']}:1991-05-17`,
|
||||||
actorUserId: null,
|
actorUserId: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -280,11 +282,11 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await prisma.notification.deleteMany({
|
await prisma.notification.deleteMany({
|
||||||
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2011' },
|
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:1991' },
|
||||||
});
|
});
|
||||||
await prisma.authorityNotice.deleteMany({ where: { year: 2011 } });
|
await prisma.authorityNotice.deleteMany({ where: { year: 1991 } });
|
||||||
await prisma.domainEvent.deleteMany({
|
await prisma.domainEvent.deleteMany({
|
||||||
where: { aggregateId: 'tsk:2011' },
|
where: { aggregateId: 'tsk:1991' },
|
||||||
});
|
});
|
||||||
await prisma.complianceSettings.update({
|
await prisma.complianceSettings.update({
|
||||||
where: { id: 'singleton' },
|
where: { id: 'singleton' },
|
||||||
@@ -293,25 +295,25 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function expectOneDraftAndNotices(): Promise<void> {
|
async function expectOneDraftAndNotices(): Promise<void> {
|
||||||
expect(await eventsOf('tsk:2011')).toEqual([
|
expect(await eventsOf('tsk:1991')).toEqual([
|
||||||
{
|
{
|
||||||
type: 'TskReferenceDayReached',
|
type: 'TskReferenceDayReached',
|
||||||
aggregateType: 'Compliance',
|
aggregateType: 'Compliance',
|
||||||
payload: { year: 2011, referenceDay: '2011-03-03' },
|
payload: { year: 1991, referenceDay: '1991-03-03' },
|
||||||
dedupeKey: 'TskReferenceDayReached:tsk:2011:2011-03-03',
|
dedupeKey: 'TskReferenceDayReached:tsk:1991:1991-03-03',
|
||||||
actorUserId: null,
|
actorUserId: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const drafts = await prisma.authorityNotice.findMany({
|
const drafts = await prisma.authorityNotice.findMany({
|
||||||
where: { kind: 'TSK_REPORT', year: 2011 },
|
where: { kind: 'TSK_REPORT', year: 1991 },
|
||||||
});
|
});
|
||||||
expect(drafts).toHaveLength(1);
|
expect(drafts).toHaveLength(1);
|
||||||
expect(drafts[0]).toMatchObject({
|
expect(drafts[0]).toMatchObject({
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
openKey: 'TSK_REPORT:2011',
|
openKey: 'TSK_REPORT:1991',
|
||||||
});
|
});
|
||||||
const notifications = await prisma.notification.findMany({
|
const notifications = await prisma.notification.findMany({
|
||||||
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2011' },
|
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:1991' },
|
||||||
select: { userId: true, priority: true, category: true, message: true },
|
select: { userId: true, priority: true, category: true, message: true },
|
||||||
});
|
});
|
||||||
// One copy per recipient, written once - and every permitted user that
|
// One copy per recipient, written once - and every permitted user that
|
||||||
@@ -333,17 +335,17 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
category: 'COMPLIANCE',
|
category: 'COMPLIANCE',
|
||||||
});
|
});
|
||||||
expect(row.message).toMatch(
|
expect(row.message).toMatch(
|
||||||
/^TSK-Stichtag 03\.03\.2011: \d+ Völker gezählt\. Bitte prüfen und melden\.$/,
|
/^TSK-Stichtag 03\.03\.1991: \d+ Völker gezählt\. Bitte prüfen und melden\.$/,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('daily evaluation twice on the reference day: one event, one draft', async () => {
|
it('daily evaluation twice on the reference day: one event, one draft', async () => {
|
||||||
const first = await step.run('2011-03-03');
|
const first = await step.run('1991-03-03');
|
||||||
const second = await step.run('2011-03-03');
|
const second = await step.run('1991-03-03');
|
||||||
await drainDomainEvents(app, ['tsk:2011']);
|
await drainDomainEvents(app, ['tsk:1991']);
|
||||||
await step.run('2011-03-04');
|
await step.run('1991-03-04');
|
||||||
await drainDomainEvents(app, ['tsk:2011']);
|
await drainDomainEvents(app, ['tsk:1991']);
|
||||||
|
|
||||||
expect(first.TskReferenceDayReached).toBe(1);
|
expect(first.TskReferenceDayReached).toBe(1);
|
||||||
expect(second.TskReferenceDayReached).toBe(0);
|
expect(second.TskReferenceDayReached).toBe(0);
|
||||||
@@ -351,9 +353,9 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('catches up a missed reference day on the 5th exactly once', async () => {
|
it('catches up a missed reference day on the 5th exactly once', async () => {
|
||||||
const catchUp = await step.run('2011-03-05');
|
const catchUp = await step.run('1991-03-05');
|
||||||
const again = await step.run('2011-03-05');
|
const again = await step.run('1991-03-05');
|
||||||
await drainDomainEvents(app, ['tsk:2011']);
|
await drainDomainEvents(app, ['tsk:1991']);
|
||||||
|
|
||||||
expect(catchUp.TskReferenceDayReached).toBe(1);
|
expect(catchUp.TskReferenceDayReached).toBe(1);
|
||||||
expect(again.TskReferenceDayReached).toBe(0);
|
expect(again.TskReferenceDayReached).toBe(0);
|
||||||
@@ -362,14 +364,14 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
|
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
|
||||||
await step.run('2011-05-20');
|
await step.run('1991-05-20');
|
||||||
|
|
||||||
expect(await eventsOf(ids['task:open'])).toEqual([
|
expect(await eventsOf(ids['task:open'])).toEqual([
|
||||||
{
|
{
|
||||||
type: 'TaskDue',
|
type: 'TaskDue',
|
||||||
aggregateType: 'Task',
|
aggregateType: 'Task',
|
||||||
payload: { dueOn: '2011-05-18' },
|
payload: { dueOn: '1991-05-18' },
|
||||||
dedupeKey: `TaskDue:${ids['task:open']}:2011-05-18`,
|
dedupeKey: `TaskDue:${ids['task:open']}:1991-05-18`,
|
||||||
actorUserId: null,
|
actorUserId: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -379,12 +381,12 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
type: 'VarroaMeasurementDue',
|
type: 'VarroaMeasurementDue',
|
||||||
aggregateType: 'Colony',
|
aggregateType: 'Colony',
|
||||||
payload: {
|
payload: {
|
||||||
dueOn: '2011-05-18',
|
dueOn: '1991-05-18',
|
||||||
lastMeasuredOn: null,
|
lastMeasuredOn: null,
|
||||||
kind: 'INTERVAL',
|
kind: 'INTERVAL',
|
||||||
anchor: 'none',
|
anchor: 'none',
|
||||||
},
|
},
|
||||||
dedupeKey: `VarroaMeasurementDue:${ids.living}:2011-05-18`,
|
dedupeKey: `VarroaMeasurementDue:${ids.living}:1991-05-18`,
|
||||||
actorUserId: null,
|
actorUserId: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -392,9 +394,9 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('InspectionOverdue follows the segment interval', async () => {
|
it('InspectionOverdue follows the segment interval', async () => {
|
||||||
await step.run('2011-05-12');
|
await step.run('1991-05-12');
|
||||||
|
|
||||||
// SCHWARMZEIT since 2011-04-15: 2011-04-28 + 7 = 2011-05-05, not + 21.
|
// SCHWARMZEIT since 1991-04-15: 1991-04-28 + 7 = 1991-05-05, not + 21.
|
||||||
expect(
|
expect(
|
||||||
(await eventsOf(ids.living)).filter(
|
(await eventsOf(ids.living)).filter(
|
||||||
(event) => event.type === 'InspectionOverdue',
|
(event) => event.type === 'InspectionOverdue',
|
||||||
@@ -404,11 +406,11 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
type: 'InspectionOverdue',
|
type: 'InspectionOverdue',
|
||||||
aggregateType: 'Colony',
|
aggregateType: 'Colony',
|
||||||
payload: {
|
payload: {
|
||||||
dueOn: '2011-05-05',
|
dueOn: '1991-05-05',
|
||||||
lastInspectedOn: '2011-04-28',
|
lastInspectedOn: '1991-04-28',
|
||||||
firstOfYear: false,
|
firstOfYear: false,
|
||||||
},
|
},
|
||||||
dedupeKey: `InspectionOverdue:${ids.living}:2011-05-06`,
|
dedupeKey: `InspectionOverdue:${ids.living}:1991-05-06`,
|
||||||
actorUserId: null,
|
actorUserId: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -416,50 +418,50 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('records no inspection in WINTERRUHE and the first one the day after FRUEHJAHR begins', async () => {
|
it('records no inspection in WINTERRUHE and the first one the day after FRUEHJAHR begins', async () => {
|
||||||
await step.run('2012-03-10');
|
await step.run('1992-03-10');
|
||||||
expect(
|
expect(
|
||||||
(await eventsOf(ids.living)).filter((event) =>
|
(await eventsOf(ids.living)).filter((event) =>
|
||||||
event.dedupeKey?.endsWith(':2012-03-21'),
|
event.dedupeKey?.endsWith(':1992-03-21'),
|
||||||
),
|
),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
|
||||||
// FRUEHJAHR begins 2012-03-20; the colony was last inspected in 2011.
|
// FRUEHJAHR begins 1992-03-20; the colony was last inspected in 1991.
|
||||||
await step.run('2012-03-21');
|
await step.run('1992-03-21');
|
||||||
expect(
|
expect(
|
||||||
(await eventsOf(ids.living)).filter((event) =>
|
(await eventsOf(ids.living)).filter((event) =>
|
||||||
event.dedupeKey?.endsWith(':2012-03-21'),
|
event.dedupeKey?.endsWith(':1992-03-21'),
|
||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
type: 'InspectionOverdue',
|
type: 'InspectionOverdue',
|
||||||
aggregateType: 'Colony',
|
aggregateType: 'Colony',
|
||||||
payload: {
|
payload: {
|
||||||
dueOn: '2012-03-20',
|
dueOn: '1992-03-20',
|
||||||
lastInspectedOn: '2011-04-28',
|
lastInspectedOn: '1991-04-28',
|
||||||
firstOfYear: true,
|
firstOfYear: true,
|
||||||
},
|
},
|
||||||
dedupeKey: `InspectionOverdue:${ids.living}:2012-03-21`,
|
dedupeKey: `InspectionOverdue:${ids.living}:1992-03-21`,
|
||||||
actorUserId: null,
|
actorUserId: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports the current queen of a living colony once she is aged, on any job day of the year', async () => {
|
it('reports the current queen of a living colony once she is aged, on any job day of the year', async () => {
|
||||||
await step.run('2011-09-09');
|
await step.run('1991-09-09');
|
||||||
await step.run('2011-09-10');
|
await step.run('1991-09-10');
|
||||||
|
|
||||||
// One per queen and year: the 2012 run of the test above added its own.
|
// One per queen and year: the 1992 run of the test above added its own.
|
||||||
const events = await eventsOf(ids['queen:aged']);
|
const events = await eventsOf(ids['queen:aged']);
|
||||||
expect(events.map((event) => event.dedupeKey)).toEqual([
|
expect(events.map((event) => event.dedupeKey)).toEqual([
|
||||||
`QueenAged:${ids['queen:aged']}:2011-01-01`,
|
`QueenAged:${ids['queen:aged']}:1991-01-01`,
|
||||||
`QueenAged:${ids['queen:aged']}:2012-01-01`,
|
`QueenAged:${ids['queen:aged']}:1992-01-01`,
|
||||||
]);
|
]);
|
||||||
expect(events.slice(0, 1)).toEqual([
|
expect(events.slice(0, 1)).toEqual([
|
||||||
{
|
{
|
||||||
type: 'QueenAged',
|
type: 'QueenAged',
|
||||||
aggregateType: 'Queen',
|
aggregateType: 'Queen',
|
||||||
payload: { hatchYear: 2009, colonyId: ids.living },
|
payload: { hatchYear: 1989, colonyId: ids.living },
|
||||||
dedupeKey: `QueenAged:${ids['queen:aged']}:2011-01-01`,
|
dedupeKey: `QueenAged:${ids['queen:aged']}:1991-01-01`,
|
||||||
actorUserId: null,
|
actorUserId: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -468,7 +470,7 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('phenology step', () => {
|
describe('phenology step', () => {
|
||||||
const DAY = '2011-04-30';
|
const DAY = '1991-04-30';
|
||||||
let weatherApiaryId = '';
|
let weatherApiaryId = '';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -483,8 +485,8 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
// 10 degrees every day from January 1st to April 29th: GTS 965.
|
// 10 degrees every day from January 1st to April 29th: GTS 965.
|
||||||
const days: Prisma.ApiaryWeatherDayCreateManyInput[] = [];
|
const days: Prisma.ApiaryWeatherDayCreateManyInput[] = [];
|
||||||
for (
|
for (
|
||||||
let t = d('2011-01-01').getTime();
|
let t = d('1991-01-01').getTime();
|
||||||
t <= d('2011-04-29').getTime();
|
t <= d('1991-04-29').getTime();
|
||||||
t += 86_400_000
|
t += 86_400_000
|
||||||
) {
|
) {
|
||||||
days.push({
|
days.push({
|
||||||
@@ -499,7 +501,7 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
data: {
|
data: {
|
||||||
apiaryId: apiary.id,
|
apiaryId: apiary.id,
|
||||||
phase: 'HOLUNDER',
|
phase: 'HOLUNDER',
|
||||||
date: d('2011-04-29'),
|
date: d('1991-04-29'),
|
||||||
userId: ids.user,
|
userId: ids.user,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -512,8 +514,8 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
await prisma.phenologyReport.deleteMany({
|
await prisma.phenologyReport.deleteMany({
|
||||||
where: { apiaryId: weatherApiaryId },
|
where: { apiaryId: weatherApiaryId },
|
||||||
});
|
});
|
||||||
// Rows of 2011 would feed the season of the other tests of this file.
|
// Rows of 1991 would feed the season of the other tests of this file.
|
||||||
await prisma.apiaryPhenology.deleteMany({ where: { year: 2011 } });
|
await prisma.apiaryPhenology.deleteMany({ where: { year: 1991 } });
|
||||||
await prisma.apiary.deleteMany({ where: { id: weatherApiaryId } });
|
await prisma.apiary.deleteMany({ where: { id: weatherApiaryId } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -532,7 +534,7 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
|
|
||||||
expect(Number(result.phenologyApiaries)).toBeGreaterThanOrEqual(2);
|
expect(Number(result.phenologyApiaries)).toBeGreaterThanOrEqual(2);
|
||||||
const rows = await prisma.apiaryPhenology.findMany({
|
const rows = await prisma.apiaryPhenology.findMany({
|
||||||
where: { apiaryId: { in: [ids.apiary, weatherApiaryId] }, year: 2011 },
|
where: { apiaryId: { in: [ids.apiary, weatherApiaryId] }, year: 1991 },
|
||||||
orderBy: { apiaryId: 'asc' },
|
orderBy: { apiaryId: 'asc' },
|
||||||
});
|
});
|
||||||
expect(rows).toHaveLength(2);
|
expect(rows).toHaveLength(2);
|
||||||
@@ -547,7 +549,7 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
// A report for HOLUNDER beats GTS and calendar.
|
// A report for HOLUNDER beats GTS and calendar.
|
||||||
expect(byApiary.get(weatherApiaryId)).toMatchObject({
|
expect(byApiary.get(weatherApiaryId)).toMatchObject({
|
||||||
gts: 965,
|
gts: 965,
|
||||||
gtsThroughDay: d('2011-04-29'),
|
gtsThroughDay: d('1991-04-29'),
|
||||||
estimatedPhase: 'APFEL',
|
estimatedPhase: 'APFEL',
|
||||||
reportedPhase: 'HOLUNDER',
|
reportedPhase: 'HOLUNDER',
|
||||||
effectivePhase: 'HOLUNDER',
|
effectivePhase: 'HOLUNDER',
|
||||||
@@ -581,25 +583,25 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('autopilot and suggestions-expiry steps', () => {
|
describe('autopilot and suggestions-expiry steps', () => {
|
||||||
/** Living colony, inspected 2011-05-03 and measured 2011-05-10. */
|
/** Living colony, inspected 1991-05-03 and measured 1991-05-10. */
|
||||||
async function lateColony(name: string): Promise<string> {
|
async function lateColony(name: string): Promise<string> {
|
||||||
const colony = await prisma.colony.create({
|
const colony = await prisma.colony.create({
|
||||||
data: {
|
data: {
|
||||||
code: `DE-E2E-${suffix}-ap-${name}`,
|
code: `DE-E2E-${suffix}-ap-${name}`,
|
||||||
apiaryId: ids.apiary,
|
apiaryId: ids.apiary,
|
||||||
colonyType: 'PRODUCTION_COLONY',
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
createdAt: d('2011-04-20'),
|
createdAt: d('1991-04-20'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
autopilotColonyIds.push(colony.id);
|
autopilotColonyIds.push(colony.id);
|
||||||
await prisma.inspection.create({
|
await prisma.inspection.create({
|
||||||
data: { colonyId: colony.id, userId: ids.user, date: d('2011-05-03') },
|
data: { colonyId: colony.id, userId: ids.user, date: d('1991-05-03') },
|
||||||
});
|
});
|
||||||
await prisma.varroaMeasurement.create({
|
await prisma.varroaMeasurement.create({
|
||||||
data: {
|
data: {
|
||||||
colonyId: colony.id,
|
colonyId: colony.id,
|
||||||
userId: ids.user,
|
userId: ids.user,
|
||||||
date: d('2011-05-10'),
|
date: d('1991-05-10'),
|
||||||
method: 'NATURAL_MITE_FALL',
|
method: 'NATURAL_MITE_FALL',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -615,10 +617,10 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
it('running autopilot and expiry twice yields the same suggestion counts', async () => {
|
it('running autopilot and expiry twice yields the same suggestion counts', async () => {
|
||||||
const colonyId = await lateColony('twice');
|
const colonyId = await lateColony('twice');
|
||||||
|
|
||||||
const first = await autopilotStep.run('2011-05-12');
|
const first = await autopilotStep.run('1991-05-12');
|
||||||
const firstExpiry = await expiryStep.run('2011-05-12');
|
const firstExpiry = await expiryStep.run('1991-05-12');
|
||||||
const second = await autopilotStep.run('2011-05-12');
|
const second = await autopilotStep.run('1991-05-12');
|
||||||
const secondExpiry = await expiryStep.run('2011-05-12');
|
const secondExpiry = await expiryStep.run('1991-05-12');
|
||||||
|
|
||||||
expect(first).toMatchObject({
|
expect(first).toMatchObject({
|
||||||
autopilotColonies: 1,
|
autopilotColonies: 1,
|
||||||
@@ -637,13 +639,13 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
|
|
||||||
it('an OPEN suggestion past expiresAt becomes EXPIRED', async () => {
|
it('an OPEN suggestion past expiresAt becomes EXPIRED', async () => {
|
||||||
const colonyId = await lateColony('expires');
|
const colonyId = await lateColony('expires');
|
||||||
await autopilotStep.run('2011-05-12');
|
await autopilotStep.run('1991-05-12');
|
||||||
|
|
||||||
// expiresAt = 2011-06-11
|
// expiresAt = 1991-06-11
|
||||||
expect(await expiryStep.run('2011-06-11')).toEqual({
|
expect(await expiryStep.run('1991-06-11')).toEqual({
|
||||||
suggestionsExpired: 0,
|
suggestionsExpired: 0,
|
||||||
});
|
});
|
||||||
expect(await expiryStep.run('2011-06-12')).toEqual({
|
expect(await expiryStep.run('1991-06-12')).toEqual({
|
||||||
suggestionsExpired: expect.any(Number) as number,
|
suggestionsExpired: expect.any(Number) as number,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -653,10 +655,10 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
|
|
||||||
it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', async () => {
|
it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', async () => {
|
||||||
const colonyId = await lateColony('again');
|
const colonyId = await lateColony('again');
|
||||||
await autopilotStep.run('2011-05-12');
|
await autopilotStep.run('1991-05-12');
|
||||||
await expiryStep.run('2011-06-12');
|
await expiryStep.run('1991-06-12');
|
||||||
|
|
||||||
const result = await autopilotStep.run('2011-06-12');
|
const result = await autopilotStep.run('1991-06-12');
|
||||||
|
|
||||||
expect(result.suggestionsOpened).toBeGreaterThanOrEqual(1);
|
expect(result.suggestionsOpened).toBeGreaterThanOrEqual(1);
|
||||||
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
|
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
|
||||||
@@ -668,13 +670,13 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
const colonyId = await lateColony('overdue20');
|
const colonyId = await lateColony('overdue20');
|
||||||
await prisma.inspection.updateMany({
|
await prisma.inspection.updateMany({
|
||||||
where: { colonyId },
|
where: { colonyId },
|
||||||
data: { date: d('2011-04-10') },
|
data: { date: d('1991-04-10') },
|
||||||
});
|
});
|
||||||
|
|
||||||
await step.run('2011-05-12');
|
await step.run('1991-05-12');
|
||||||
await autopilotStep.run('2011-05-12');
|
await autopilotStep.run('1991-05-12');
|
||||||
|
|
||||||
// Due 2011-04-17: the event of 04-18 is outside the 7-day catch-up.
|
// Due 1991-04-17: the event of 04-18 is outside the 7-day catch-up.
|
||||||
expect(
|
expect(
|
||||||
await prisma.domainEvent.count({
|
await prisma.domainEvent.count({
|
||||||
where: { aggregateId: colonyId, type: 'InspectionOverdue' },
|
where: { aggregateId: colonyId, type: 'InspectionOverdue' },
|
||||||
@@ -682,19 +684,19 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
).toBe(0);
|
).toBe(0);
|
||||||
const [suggestion] = await inspectSuggestions(colonyId);
|
const [suggestion] = await inspectSuggestions(colonyId);
|
||||||
expect(suggestion).toMatchObject({ status: 'OPEN', priority: 'HIGH' });
|
expect(suggestion).toMatchObject({ status: 'OPEN', priority: 'HIGH' });
|
||||||
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2011-04-17');
|
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('1991-04-17');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
|
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
|
||||||
const colonyId = await lateColony('returns');
|
const colonyId = await lateColony('returns');
|
||||||
await autopilotStep.run('2011-05-12');
|
await autopilotStep.run('1991-05-12');
|
||||||
const fresh = await prisma.inspection.create({
|
const fresh = await prisma.inspection.create({
|
||||||
data: { colonyId, userId: ids.user, date: d('2011-05-12') },
|
data: { colonyId, userId: ids.user, date: d('1991-05-12') },
|
||||||
});
|
});
|
||||||
const resolved = await autopilotStep.run('2011-05-12');
|
const resolved = await autopilotStep.run('1991-05-12');
|
||||||
await prisma.inspection.delete({ where: { id: fresh.id } });
|
await prisma.inspection.delete({ where: { id: fresh.id } });
|
||||||
|
|
||||||
const returned = await autopilotStep.run('2011-05-12');
|
const returned = await autopilotStep.run('1991-05-12');
|
||||||
|
|
||||||
expect(resolved.suggestionsResolved).toBeGreaterThanOrEqual(1);
|
expect(resolved.suggestionsResolved).toBeGreaterThanOrEqual(1);
|
||||||
expect(returned.suggestionsOpened).toBeGreaterThanOrEqual(1);
|
expect(returned.suggestionsOpened).toBeGreaterThanOrEqual(1);
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ describe('Jobs (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
// Only the jobs this file runs: other files (rekey, app-backup, voice)
|
||||||
|
// record their own runs in the same window and read them back.
|
||||||
const runs = await prisma.jobRun.findMany({
|
const runs = await prisma.jobRun.findMany({
|
||||||
where: { startedAt: { gte: testStart } },
|
where: {
|
||||||
|
job: { in: ['cleanup', 'daily-evaluation', 'task-templates'] },
|
||||||
|
startedAt: { gte: testStart },
|
||||||
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
const runIds = [...jobRunIds, ...runs.map((run) => run.id)];
|
const runIds = [...jobRunIds, ...runs.map((run) => run.id)];
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ describe('Notifications (e2e)', () => {
|
|||||||
ids[who] = user.id;
|
ids[who] = user.id;
|
||||||
tokens[who] = (await login(app, emails[who], password)).accessToken;
|
tokens[who] = (await login(app, emails[who], password)).accessToken;
|
||||||
}
|
}
|
||||||
});
|
// App start plus four bcrypt-12 logins exceeded the 20 s default on a
|
||||||
|
// busy CI runner (run 826).
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
const userIds = Object.values(ids);
|
const userIds = Object.values(ids);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ RUN npm run lint && npx tsc --noEmit && npm test
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
# Bundle-Budget (Spec Beute-3D §9.9): schlaegt fehl, wenn der 3D-Chunk > 320 kB gzip ist.
|
# Bundle-Budget (Spec Beute-3D §9.9): schlaegt fehl, wenn der 3D-Chunk > 320 kB gzip ist.
|
||||||
RUN npm run check:3d-budget
|
RUN npm run check:3d-budget
|
||||||
|
# Bundle-Budget (Spec T5 §17.6): schlaegt fehl, wenn motion zusammen > 30 kB gzip ist.
|
||||||
|
RUN npm run check:motion-budget
|
||||||
|
|
||||||
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"eslint-config-next": "16.3.8",
|
"eslint-config-next": "16.3.8",
|
||||||
"fake-indexeddb": "^6.2.5",
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
|
"sharp": "0.35.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^4.1.11"
|
||||||
@@ -1781,8 +1782,8 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -11158,8 +11159,8 @@
|
|||||||
"version": "0.35.5",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||||
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.1.0",
|
"@img/colour": "^1.1.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -11208,8 +11209,8 @@
|
|||||||
"version": "7.8.5",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
"typecheck": "next typegen && tsc --noEmit",
|
"typecheck": "next typegen && tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"check:3d-budget": "node scripts/check-3d-budget.mjs"
|
"check:3d-budget": "node scripts/check-3d-budget.mjs",
|
||||||
|
"check:motion-budget": "node scripts/check-motion-budget.mjs",
|
||||||
|
"icons": "node scripts/generate-icons.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
"eslint-config-next": "16.3.8",
|
"eslint-config-next": "16.3.8",
|
||||||
"fake-indexeddb": "^6.2.5",
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
|
"sharp": "0.35.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^4.1.11"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<!-- ApiCore mark (spec 12.1): a capped honeycomb cell. Master for scripts/generate-icons.mjs;
|
||||||
|
the geometry equals BRAND_CELL_PATHS in src/components/brand-mark.tsx. -->
|
||||||
|
<path id="cell" d="M32 4 L56 18 V46 L32 60 L8 46 V18 Z" fill="#f0b13f" stroke="#2c1b0f" stroke-width="3.84" stroke-linejoin="round" />
|
||||||
|
<path id="lid" d="M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z" fill="#f9d280" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 477 B |
@@ -1,8 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112.64" fill="#2c1b0f"/><g transform="translate(51.2 51.2) scale(6.4)"><path d="M32 4 L56 18 V46 L32 60 L8 46 V18 Z" fill="#f0b13f" stroke="#2c1b0f" stroke-width="3.84" stroke-linejoin="round" /><path d="M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z" fill="#f9d280" /></g></svg>
|
||||||
<rect width="512" height="512" rx="96" fill="#0a0a0a" />
|
|
||||||
<g fill="#f5c518">
|
|
||||||
<rect x="156" y="176" width="200" height="52" rx="10" />
|
|
||||||
<rect x="156" y="242" width="200" height="52" rx="10" />
|
|
||||||
<rect x="156" y="308" width="200" height="52" rx="10" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 340 B After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 620 B |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -37,11 +37,28 @@ const {
|
|||||||
buildAssetsToPrune,
|
buildAssetsToPrune,
|
||||||
} = self.ApiCoreSwRoutes;
|
} = self.ApiCoreSwRoutes;
|
||||||
|
|
||||||
const SHELL_CACHE = "apicore-shell-v4";
|
// v5: new app icons and colour tokens (T5 spec 12.3) reach installed apps.
|
||||||
|
const SHELL_CACHE = "apicore-shell-v5";
|
||||||
// v2: v1 could hold answers this version never keeps (exports, downloads).
|
// v2: v1 could hold answers this version never keeps (exports, downloads).
|
||||||
const DATA_CACHE = "apicore-data-v2";
|
const DATA_CACHE = "apicore-data-v2";
|
||||||
const KNOWN_CACHES = [SHELL_CACHE, DATA_CACHE];
|
const KNOWN_CACHES = [SHELL_CACHE, DATA_CACHE];
|
||||||
|
|
||||||
|
// The manifest icons (src/app/manifest.ts; sw.spec checks that every one of
|
||||||
|
// them is kept): kept with the shell, so an offline start and the shortcuts
|
||||||
|
// show the app's own icons. Not the favicon: Next links it as
|
||||||
|
// /favicon.ico?<hash>, so a cached /favicon.ico would never answer it.
|
||||||
|
const SHELL_ICONS = [
|
||||||
|
"/icon.svg",
|
||||||
|
"/icons/icon-192.png",
|
||||||
|
"/icons/icon-512.png",
|
||||||
|
"/icons/maskable-192.png",
|
||||||
|
"/icons/maskable-512.png",
|
||||||
|
"/icons/monochrome-512.png",
|
||||||
|
"/icons/shortcut-inspection-96.png",
|
||||||
|
"/icons/shortcut-varroa-96.png",
|
||||||
|
"/icons/shortcut-today-96.png",
|
||||||
|
];
|
||||||
|
|
||||||
// Same budget as every request of the app (lib/http/fetch-with-timeout.ts).
|
// Same budget as every request of the app (lib/http/fetch-with-timeout.ts).
|
||||||
const ASSET_TIMEOUT_MS = 4000;
|
const ASSET_TIMEOUT_MS = 4000;
|
||||||
const RUNTIME_CONFIG_PATH = "/api/runtime-config";
|
const RUNTIME_CONFIG_PATH = "/api/runtime-config";
|
||||||
@@ -453,6 +470,7 @@ async function warmShell() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await keepRuntimeConfig(cache).catch(() => undefined);
|
await keepRuntimeConfig(cache).catch(() => undefined);
|
||||||
|
await keepIcons(cache);
|
||||||
// Only with the full picture of the current build may old assets go.
|
// Only with the full picture of the current build may old assets go.
|
||||||
if (allPagesKept) {
|
if (allPagesKept) {
|
||||||
await pruneOldBuildAssets(cache, [...assets]);
|
await pruneOldBuildAssets(cache, [...assets]);
|
||||||
@@ -497,6 +515,21 @@ async function keepAsset(cache, asset) {
|
|||||||
await cache.put(asset, response);
|
await cache.put(asset, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the icons fresh on every warm-up (their URLs carry no build hash).
|
||||||
|
* A missing icon is skipped; the pages do not depend on it.
|
||||||
|
*/
|
||||||
|
async function keepIcons(cache) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
SHELL_ICONS.map(async (icon) => {
|
||||||
|
const response = await fetch(icon, { cache: "no-cache" });
|
||||||
|
if (isGoodAnswer(response)) {
|
||||||
|
await cache.put(icon, response);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function keepRuntimeConfig(cache) {
|
async function keepRuntimeConfig(cache) {
|
||||||
const response = await fetch(RUNTIME_CONFIG_PATH, { cache: "no-cache" });
|
const response = await fetch(RUNTIME_CONFIG_PATH, { cache: "no-cache" });
|
||||||
if (isGoodAnswer(response)) {
|
if (isGoodAnswer(response)) {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Bundle-Budget fuer `motion` im DOM (Spec T5 §17.6: hoechstens 30 kB gzip).
|
||||||
|
// Laeuft nach `next build` wie check-3d-budget.mjs; Exit 1, wenn die motion-Chunks
|
||||||
|
// zusammen das Budget ueberschreiten oder gar kein motion-Chunk gefunden wird.
|
||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
|
||||||
|
// Spec §17.6: hoechstens 30 kB gzip (1 kB = 1000 Byte) fuer motion insgesamt:
|
||||||
|
// den Teil in der App-Huelle und die asynchron geladenen Features.
|
||||||
|
export const BUDGET_BYTES = 30_000;
|
||||||
|
|
||||||
|
// String-Literale, die motion auch minifiziert behaelt und die kein App-Code schreibt:
|
||||||
|
// der Stil-Schluessel der Render-Basis, das Attribut von AnimatePresence (popLayout)
|
||||||
|
// und die Gesten-Props der Features.
|
||||||
|
export const MOTION_MARKERS = ["transformPerspective", "data-motion-pop-id", "whileHover"];
|
||||||
|
const THREE_MARKERS = ["WebGLRenderer", "__THREE__"];
|
||||||
|
|
||||||
|
export function isMotionChunk(source) {
|
||||||
|
if (THREE_MARKERS.every((marker) => source.includes(marker))) return false;
|
||||||
|
return MOTION_MARKERS.some((marker) => source.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Misst alle motion-Chunks unter chunksDir (gzip level 9) und zaehlt sie zusammen.
|
||||||
|
* @returns {{ ok: boolean, files: { file: string, gzipBytes: number }[], gzipBytes: number, budget: number, message: string }}
|
||||||
|
*/
|
||||||
|
export function checkMotionBudget(chunksDir, budget = BUDGET_BYTES) {
|
||||||
|
const files = [];
|
||||||
|
for (const name of readdirSync(chunksDir)) {
|
||||||
|
if (!name.endsWith(".js")) continue;
|
||||||
|
const source = readFileSync(path.join(chunksDir, name));
|
||||||
|
if (!isMotionChunk(source.toString("latin1"))) continue;
|
||||||
|
files.push({ file: name, gzipBytes: gzipSync(source, { level: 9 }).length });
|
||||||
|
}
|
||||||
|
const gzipBytes = files.reduce((sum, entry) => sum + entry.gzipBytes, 0);
|
||||||
|
if (files.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
files,
|
||||||
|
gzipBytes,
|
||||||
|
budget,
|
||||||
|
message: `Kein motion-Chunk in ${chunksDir} gefunden (Marker: ${MOTION_MARKERS.join(", ")}). Check kaputt oder Bundle umgebaut?`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ok = gzipBytes <= budget;
|
||||||
|
const list = files.map((entry) => `${entry.file} ${entry.gzipBytes}`).join(", ");
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
files,
|
||||||
|
gzipBytes,
|
||||||
|
budget,
|
||||||
|
message: `motion: ${gzipBytes} Byte gzip in ${files.length} Chunks (${list}), Budget ${budget} Byte${ok ? "" : " UEBERSCHRITTEN"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const chunksDir = path.resolve(process.cwd(), ".next/static/chunks");
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = checkMotionBudget(chunksDir);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`check-motion-budget: ${chunksDir} nicht lesbar (zuerst next build?): ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
(result.ok ? console.log : console.error)(result.message);
|
||||||
|
process.exit(result.ok ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { BUDGET_BYTES, MOTION_MARKERS, checkMotionBudget, isMotionChunk } from "./check-motion-budget.mjs";
|
||||||
|
|
||||||
|
const motionChunk = (filler: string, marker = MOTION_MARKERS[0]) => `/*${marker}*/var a="${marker}";${filler}`;
|
||||||
|
// Hex of random bytes is barely compressible by gzip (about 50 %).
|
||||||
|
const noise = (bytes: number) => randomBytes(bytes).toString("hex");
|
||||||
|
|
||||||
|
describe("check-motion-budget (spec 17.6: motion at most 30 kB gzip)", () => {
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(path.join(tmpdir(), "motion-budget-"));
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("adds up every motion chunk (the shell part and the lazily loaded features)", () => {
|
||||||
|
writeFileSync(path.join(dir, "core.js"), motionChunk(noise(500), MOTION_MARKERS[0]));
|
||||||
|
writeFileSync(path.join(dir, "features.js"), motionChunk(noise(500), MOTION_MARKERS.at(-1)));
|
||||||
|
writeFileSync(path.join(dir, "app.js"), "console.log('Außerdem heute')");
|
||||||
|
const result = checkMotionBudget(dir);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.files.map(({ file }) => file).sort()).toEqual(["core.js", "features.js"]);
|
||||||
|
expect(result.gzipBytes).toBe(result.files.reduce((sum, { gzipBytes }) => sum + gzipBytes, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the motion chunks together are over budget", () => {
|
||||||
|
writeFileSync(path.join(dir, "a.js"), motionChunk(noise(400)));
|
||||||
|
writeFileSync(path.join(dir, "b.js"), motionChunk(noise(400)));
|
||||||
|
const result = checkMotionBudget(dir, 600);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.gzipBytes).toBeGreaterThan(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when no motion chunk exists (check broken or bundle rebuilt)", () => {
|
||||||
|
writeFileSync(path.join(dir, "app.js"), "export const a = 1;");
|
||||||
|
const result = checkMotionBudget(dir);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.files).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count the three.js chunk", () => {
|
||||||
|
expect(isMotionChunk(`/*WebGLRenderer*/window.__THREE__="186";${MOTION_MARKERS[0]}`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the spec budget of 30 kB by default", () => {
|
||||||
|
expect(BUDGET_BYTES).toBe(30_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* T5 colour codemod (spec 4.2 and 10.8 step 3). Pure and idempotent:
|
||||||
|
*
|
||||||
|
* - `text-primary` -> `text-primary-ink` (honey is a fill, the ink is the text colour);
|
||||||
|
* `text-primary-foreground`, `text-primary/NN`, `bg-primary…` stay.
|
||||||
|
* - `text-warning` -> `text-warning-ink`.
|
||||||
|
* - `text-warning-foreground` and `text-condition-x-foreground` -> `-ink`, unless the same line
|
||||||
|
* puts the text on the solid surface (`bg-warning`, `bg-condition-x` without `/NN`): on a tint
|
||||||
|
* or on the card only the ink reaches 4.5:1 in every set.
|
||||||
|
* - `border-primary` -> `border-foreground` where the line, or one of the two lines above it,
|
||||||
|
* names a selection state (checked, pressed, selected); `border-primary/NN` stays.
|
||||||
|
*
|
||||||
|
* CLI: node scripts/codemod-t5-colors.mjs [--check] <file or directory…>
|
||||||
|
* --check lists the files that would change and exits 1 if there are any.
|
||||||
|
*/
|
||||||
|
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/** Start of a class token: line start, whitespace, quote, backtick, `{`, `(` or a variant `:`. */
|
||||||
|
const BEFORE = String.raw`(?<=^|[\s"'\`{(:!])`;
|
||||||
|
/** End of a class token: no further name characters, no `/NN` opacity, no `-suffix`. */
|
||||||
|
const AFTER = String.raw`(?![\w/-])`;
|
||||||
|
|
||||||
|
const TONES = String.raw`(?:warning|condition-(?:excellent|good|average|weak|critical))`;
|
||||||
|
const SELECTION_STATE = /checked|pressed|selected/;
|
||||||
|
const SELECTION_CONTEXT_LINES = 2;
|
||||||
|
|
||||||
|
const TEXT_PRIMARY = new RegExp(`${BEFORE}text-primary${AFTER}`, "g");
|
||||||
|
const TEXT_WARNING = new RegExp(`${BEFORE}text-warning${AFTER}`, "g");
|
||||||
|
const TONE_FOREGROUND = new RegExp(`${BEFORE}text-(${TONES})-foreground${AFTER}`, "g");
|
||||||
|
const BORDER_PRIMARY = new RegExp(`${BEFORE}border-primary${AFTER}`, "g");
|
||||||
|
|
||||||
|
function hasSolidSurface(line, tone) {
|
||||||
|
return new RegExp(`${BEFORE}bg-${tone}${AFTER}`).test(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLine(line, context) {
|
||||||
|
let out = line
|
||||||
|
.replace(TEXT_PRIMARY, "text-primary-ink")
|
||||||
|
.replace(TEXT_WARNING, "text-warning-ink")
|
||||||
|
.replace(TONE_FOREGROUND, (match, tone) => (hasSolidSurface(line, tone) ? match : `text-${tone}-ink`));
|
||||||
|
if (SELECTION_STATE.test(context)) out = out.replace(BORDER_PRIMARY, "border-foreground");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies the colour rules to one source text; line endings are kept. */
|
||||||
|
export function transform(source) {
|
||||||
|
const lines = source.split("\n");
|
||||||
|
return lines
|
||||||
|
.map((line, index) => {
|
||||||
|
const context = lines.slice(Math.max(0, index - SELECTION_CONTEXT_LINES), index + 1).join("\n");
|
||||||
|
return transformLine(line, context);
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFiles(path) {
|
||||||
|
if (statSync(path).isDirectory()) {
|
||||||
|
return readdirSync(path).flatMap((name) => sourceFiles(join(path, name)));
|
||||||
|
}
|
||||||
|
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(args) {
|
||||||
|
const check = args.includes("--check");
|
||||||
|
const targets = args.filter((arg) => arg !== "--check");
|
||||||
|
if (targets.length === 0) {
|
||||||
|
console.error("usage: node scripts/codemod-t5-colors.mjs [--check] <file or directory…>");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
const changed = targets.flatMap(sourceFiles).filter((file) => {
|
||||||
|
const before = readFileSync(file, "utf8");
|
||||||
|
const after = transform(before);
|
||||||
|
if (after === before) return false;
|
||||||
|
if (!check) writeFileSync(file, after);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
for (const file of changed) console.log(file);
|
||||||
|
console.log(`${changed.length} file(s) ${check ? "to migrate" : "migrated"}`);
|
||||||
|
return check && changed.length > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
process.exitCode = main(process.argv.slice(2));
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { transform } from "./codemod-t5-colors.mjs";
|
||||||
|
|
||||||
|
describe("codemod t5 colours (spec 10.8 step 3)", () => {
|
||||||
|
it("text-primary becomes text-primary-ink", () => {
|
||||||
|
expect(transform('<a className="text-sm text-primary hover:underline">')).toBe(
|
||||||
|
'<a className="text-sm text-primary-ink hover:underline">',
|
||||||
|
);
|
||||||
|
expect(transform('cn("data-active:text-primary", x)')).toBe('cn("data-active:text-primary-ink", x)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("text-primary-foreground, bg-primary and text-primary/80 stay unchanged", () => {
|
||||||
|
const source = 'className="bg-primary text-primary-foreground text-primary/80 text-primary/[0.07] bg-primary/10"';
|
||||||
|
expect(transform(source)).toBe(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("text-warning becomes text-warning-ink but text-warning-foreground stays on a solid warning surface", () => {
|
||||||
|
expect(transform('<AlertTriangle className="size-4 text-warning" />')).toBe(
|
||||||
|
'<AlertTriangle className="size-4 text-warning-ink" />',
|
||||||
|
);
|
||||||
|
const solid = 'className="bg-warning text-warning-foreground"';
|
||||||
|
expect(transform(solid)).toBe(solid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warning text on a tint or on the card becomes the ink", () => {
|
||||||
|
expect(transform('"bg-warning/15 text-warning-foreground"')).toBe('"bg-warning/15 text-warning-ink"');
|
||||||
|
expect(transform('<span className="text-warning-foreground">')).toBe('<span className="text-warning-ink">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("badge classes with text-condition-good-foreground become -ink", () => {
|
||||||
|
expect(transform('GOOD: "border-transparent bg-condition-good/15 text-condition-good-foreground",')).toBe(
|
||||||
|
'GOOD: "border-transparent bg-condition-good/15 text-condition-good-ink",',
|
||||||
|
);
|
||||||
|
expect(transform('<CheckCircle2 className="size-5 text-condition-critical-foreground" />')).toBe(
|
||||||
|
'<CheckCircle2 className="size-5 text-condition-critical-ink" />',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("text on a solid condition surface keeps its foreground", () => {
|
||||||
|
const solid = '4: "border-condition-good bg-condition-good text-condition-good-foreground",';
|
||||||
|
expect(transform(solid)).toBe(solid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("border-primary on a selection state becomes border-foreground", () => {
|
||||||
|
expect(transform('"data-checked:border-primary data-checked:bg-primary"')).toBe(
|
||||||
|
'"data-checked:border-foreground data-checked:bg-primary"',
|
||||||
|
);
|
||||||
|
expect(transform('className="has-[:checked]:border-primary has-[:checked]:bg-primary/10"')).toBe(
|
||||||
|
'className="has-[:checked]:border-foreground has-[:checked]:bg-primary/10"',
|
||||||
|
);
|
||||||
|
// The state may sit on the line(s) just above the class string.
|
||||||
|
expect(transform('selected &&\n "border-primary bg-primary font-medium",')).toBe(
|
||||||
|
'selected &&\n "border-foreground bg-primary font-medium",',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("border-primary without a selection state and translucent borders stay", () => {
|
||||||
|
const plain = 'className={ok ? "rounded-lg border border-primary bg-primary/5" : "border-destructive"}';
|
||||||
|
expect(transform(plain)).toBe(plain);
|
||||||
|
const translucent = 'className={selected ? "border-primary/40" : ""}';
|
||||||
|
expect(transform(translucent)).toBe(translucent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps CRLF line endings", () => {
|
||||||
|
expect(transform('a\r\nclassName="text-primary"\r\n')).toBe('a\r\nclassName="text-primary-ink"\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent", () => {
|
||||||
|
const source = [
|
||||||
|
'className="text-primary text-warning bg-warning/15 text-warning-foreground"',
|
||||||
|
'checked ? "border-primary bg-primary/10" : "border-border"',
|
||||||
|
'"bg-condition-weak/15 text-condition-weak-foreground"',
|
||||||
|
].join("\n");
|
||||||
|
const once = transform(source);
|
||||||
|
expect(once).not.toBe(source);
|
||||||
|
expect(transform(once)).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves nothing to migrate in src", () => {
|
||||||
|
const files = sourceFiles(join(process.cwd(), "src"));
|
||||||
|
expect(files.length).toBeGreaterThan(100);
|
||||||
|
const pending = files.filter((file) => {
|
||||||
|
const text = readFileSync(file, "utf8");
|
||||||
|
return transform(text) !== text;
|
||||||
|
});
|
||||||
|
expect(pending).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function sourceFiles(dir) {
|
||||||
|
return readdirSync(dir).flatMap((name) => {
|
||||||
|
const path = join(dir, name);
|
||||||
|
if (statSync(path).isDirectory()) return sourceFiles(path);
|
||||||
|
return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* T5 focus and size codemod (spec 4.5, 4.6 and 10.8 step 5). Pure and idempotent:
|
||||||
|
*
|
||||||
|
* - One focus style: the base rule `:where(:focus-visible)` in globals.css draws it. Component
|
||||||
|
* classes that remove the outline or draw an own ring go: `outline-none` (also behind
|
||||||
|
* `focus:`/`focus-visible:`), `focus-visible:ring-3`, `focus-visible:ring-ring/50`,
|
||||||
|
* `focus-visible:border-ring`, own outline rings (`focus-visible:outline-2 …-ring`, the hive
|
||||||
|
* glass edge ring, which `.holo-stage` now provides) and variant focus colours.
|
||||||
|
* - A row whose focus target sits inside (label with its input) keeps one ring on the row:
|
||||||
|
* `has-[:focus-visible]:outline-*`/`ring-*` become the utility `focus-ring-within`.
|
||||||
|
* - Compact sizes follow the pointer, not the viewport: `sm:h-*`/`sm:min-h-*`/`sm:size-*` (also
|
||||||
|
* behind a further variant) become `pointer-fine:…`.
|
||||||
|
* - `focus:outline-2 focus:outline-ring` stays: it marks elements focused by script after a
|
||||||
|
* click, where :focus-visible does not match (guarded with an allow-list).
|
||||||
|
* - A class string that held nothing but focus classes is dropped together with its line.
|
||||||
|
*
|
||||||
|
* Selection outlines (`outline-2 outline-foreground`) and menu items' `outline-hidden` stay.
|
||||||
|
*
|
||||||
|
* CLI: node scripts/codemod-t5-focus.mjs [--check] <file or directory…>
|
||||||
|
* --check lists the files that would change and exits 1 if there are any.
|
||||||
|
*/
|
||||||
|
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/** Characters that belong to a class token (so a match never starts or ends inside one). */
|
||||||
|
const TOKEN_CHAR = String.raw`[\w:/\[\]().%-]`;
|
||||||
|
|
||||||
|
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
||||||
|
|
||||||
|
/** Classes removed outright. */
|
||||||
|
const REMOVED = [
|
||||||
|
"outline-none",
|
||||||
|
"focus:outline-none",
|
||||||
|
"focus-visible:outline-none",
|
||||||
|
"focus-visible:ring-3",
|
||||||
|
"focus-visible:ring-[3px]",
|
||||||
|
"focus-visible:ring-ring/50",
|
||||||
|
"focus-visible:border-ring",
|
||||||
|
"focus:ring-3",
|
||||||
|
"focus:ring-ring/50",
|
||||||
|
"focus-visible:outline-1",
|
||||||
|
"focus-visible:outline-2",
|
||||||
|
"focus-visible:outline-ring",
|
||||||
|
"focus-visible:outline-offset-2",
|
||||||
|
"focus-visible:outline-holo-edge-strong",
|
||||||
|
"focus-visible:ring-destructive/20",
|
||||||
|
"focus-visible:border-destructive/40",
|
||||||
|
"focus-visible:ring-warning/30",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Own rings on a row with an inner focus target; replaced by one `focus-ring-within`. */
|
||||||
|
const WITHIN = [
|
||||||
|
"has-[:focus-visible]:outline-2",
|
||||||
|
"has-[:focus-visible]:outline-ring",
|
||||||
|
"has-[:focus-visible]:ring-3",
|
||||||
|
"has-[:focus-visible]:ring-ring/50",
|
||||||
|
"has-focus-visible:ring-3",
|
||||||
|
"has-focus-visible:ring-ring/50",
|
||||||
|
];
|
||||||
|
const WITHIN_UTILITY = "focus-ring-within";
|
||||||
|
|
||||||
|
function alternativesOf(classes) {
|
||||||
|
return `(?:${classes.map(escape).join("|")})(?!${TOKEN_CHAR})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the classes so that exactly one separator stays: a class after a space goes with
|
||||||
|
* that space, a class at the start of a string goes with the space after it.
|
||||||
|
*/
|
||||||
|
function removeClasses(line, classes) {
|
||||||
|
const alternatives = alternativesOf(classes);
|
||||||
|
return line
|
||||||
|
.replace(new RegExp(` ${alternatives}`, "g"), "")
|
||||||
|
.replace(new RegExp(`(?<!${TOKEN_CHAR})${alternatives} ?`, "g"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const WITHIN_FIRST = new RegExp(`(?<!${TOKEN_CHAR})${alternativesOf(WITHIN)}`);
|
||||||
|
/** `sm:` plus any further variants (`sm:data-[size=default]:`) in front of `h-`/`min-h-`/`size-`. */
|
||||||
|
const SM_HEIGHT = new RegExp(String.raw`(?<!${TOKEN_CHAR})sm:((?:[^\s:"'\x60]+:)*)((?:min-)?h-|size-)`, "g");
|
||||||
|
const EMPTY_CLASS_LINE = /^\s*(["'`])\1,?\s*\r?$/;
|
||||||
|
|
||||||
|
function transformLine(line) {
|
||||||
|
let out = removeClasses(line, REMOVED);
|
||||||
|
if (WITHIN_FIRST.test(out)) {
|
||||||
|
out = removeClasses(out.replace(WITHIN_FIRST, WITHIN_UTILITY), WITHIN);
|
||||||
|
}
|
||||||
|
return out.replace(SM_HEIGHT, "pointer-fine:$1$2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies the focus and size rules to one source text; line endings are kept. */
|
||||||
|
export function transform(source) {
|
||||||
|
const lines = source.split("\n");
|
||||||
|
const result = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const out = transformLine(line);
|
||||||
|
if (out !== line && EMPTY_CLASS_LINE.test(out)) continue;
|
||||||
|
result.push(out);
|
||||||
|
}
|
||||||
|
return result.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFiles(path) {
|
||||||
|
if (statSync(path).isDirectory()) {
|
||||||
|
return readdirSync(path).flatMap((name) => sourceFiles(join(path, name)));
|
||||||
|
}
|
||||||
|
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(args) {
|
||||||
|
const check = args.includes("--check");
|
||||||
|
const targets = args.filter((arg) => arg !== "--check");
|
||||||
|
if (targets.length === 0) {
|
||||||
|
console.error("usage: node scripts/codemod-t5-focus.mjs [--check] <file or directory…>");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
const changed = targets.flatMap(sourceFiles).filter((file) => {
|
||||||
|
const before = readFileSync(file, "utf8");
|
||||||
|
const after = transform(before);
|
||||||
|
if (after === before) return false;
|
||||||
|
if (!check) writeFileSync(file, after);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
for (const file of changed) console.log(file);
|
||||||
|
console.log(`${changed.length} file(s) ${check ? "to migrate" : "migrated"}`);
|
||||||
|
return check && changed.length > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
process.exitCode = main(process.argv.slice(2));
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { transform } from "./codemod-t5-focus.mjs";
|
||||||
|
|
||||||
|
describe("codemod t5 focus and sizes (spec 4.5, 4.6, 10.8 step 5)", () => {
|
||||||
|
it("drops outline-none and the component focus ring classes", () => {
|
||||||
|
expect(
|
||||||
|
transform(
|
||||||
|
'"min-h-11 rounded-lg px-2 outline-none hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-60"',
|
||||||
|
),
|
||||||
|
).toBe('"min-h-11 rounded-lg px-2 hover:bg-muted disabled:opacity-60"');
|
||||||
|
expect(transform('className="px-2 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"')).toBe(
|
||||||
|
'className="px-2"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the own outline rings, including the stage edge ring of the hive glass", () => {
|
||||||
|
expect(transform('"rounded-full focus-visible:outline-2 focus-visible:outline-ring"')).toBe('"rounded-full"');
|
||||||
|
expect(
|
||||||
|
transform("`h-11 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong ${X}`"),
|
||||||
|
).toBe("`h-11 ${X}`");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the focus: outline of elements focused by script after a click", () => {
|
||||||
|
// :focus-visible does not match a script focus that follows a pointer click.
|
||||||
|
const scripted = '"rounded-xl border p-3 focus:outline-2 focus:outline-ring"';
|
||||||
|
expect(transform(scripted)).toBe(scripted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the skip link's own focus ring", () => {
|
||||||
|
expect(transform('"sr-only focus:not-sr-only focus:ring-3 focus:ring-ring/50 focus:outline-none"')).toBe(
|
||||||
|
'"sr-only focus:not-sr-only"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops variant focus colours on the destructive button and badges", () => {
|
||||||
|
expect(transform('"bg-destructive/10 text-destructive focus-visible:border-destructive/40 focus-visible:ring-destructive/20"')).toBe(
|
||||||
|
'"bg-destructive/10 text-destructive"',
|
||||||
|
);
|
||||||
|
expect(transform('"bg-warning/15 focus-visible:ring-warning/30 [a]:hover:bg-warning/25"')).toBe(
|
||||||
|
'"bg-warning/15 [a]:hover:bg-warning/25"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a row with an inner focus target keeps one ring on the outer element", () => {
|
||||||
|
expect(transform('"flex gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"')).toBe(
|
||||||
|
'"flex gap-3 rounded-xl border p-3 focus-ring-within"',
|
||||||
|
);
|
||||||
|
expect(transform('"flex min-h-14 px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50"')).toBe(
|
||||||
|
'"flex min-h-14 px-3 focus-ring-within"',
|
||||||
|
);
|
||||||
|
expect(transform('"flex min-h-11 px-1 has-focus-visible:ring-3 has-focus-visible:ring-ring/50"')).toBe(
|
||||||
|
'"flex min-h-11 px-1 focus-ring-within"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selection outlines that are not focus styles stay", () => {
|
||||||
|
const calendar = 'isSelected && "outline-2 -outline-offset-2 outline-foreground",';
|
||||||
|
expect(transform(calendar)).toBe(calendar);
|
||||||
|
const canvas = 'selected && "outline outline-2 outline-foreground",';
|
||||||
|
expect(transform(canvas)).toBe(canvas);
|
||||||
|
const menuItem = '"relative flex rounded-md text-sm outline-hidden select-none focus:bg-accent"';
|
||||||
|
expect(transform(menuItem)).toBe(menuItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a class string that only held focus classes disappears with its line", () => {
|
||||||
|
expect(transform('cn(\n "min-h-11 px-2",\n "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",\n x,\n)')).toBe(
|
||||||
|
'cn(\n "min-h-11 px-2",\n x,\n)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sm:h-* and sm:min-h-* become pointer-fine, also behind a further variant", () => {
|
||||||
|
expect(transform('"h-10 sm:h-8 gap-1.5"')).toBe('"h-10 pointer-fine:h-8 gap-1.5"');
|
||||||
|
expect(transform('"min-h-11 sm:min-h-9 rounded-lg"')).toBe('"min-h-11 pointer-fine:min-h-9 rounded-lg"');
|
||||||
|
expect(transform('"data-[size=default]:h-11 sm:data-[size=default]:h-9"')).toBe(
|
||||||
|
'"data-[size=default]:h-11 pointer-fine:data-[size=default]:h-9"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sm:size-* becomes pointer-fine:size-*", () => {
|
||||||
|
expect(transform('className="size-11 sm:size-8"')).toBe('className="size-11 pointer-fine:size-8"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("other sm: classes stay", () => {
|
||||||
|
const layout = '"hidden sm:flex sm:hidden sm:gap-2 sm:px-4 sm:max-w-xs sm:max-h-96"';
|
||||||
|
expect(transform(layout)).toBe(layout);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps CRLF line endings", () => {
|
||||||
|
expect(transform('a\r\nclassName="px-2 outline-none"\r\n')).toBe('a\r\nclassName="px-2"\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the focus codemod is idempotent and keeps pointer-fine classes", () => {
|
||||||
|
const source = [
|
||||||
|
'"h-11 sm:h-8 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"',
|
||||||
|
'"pointer-fine:h-9 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"',
|
||||||
|
].join("\n");
|
||||||
|
const once = transform(source);
|
||||||
|
expect(once).toBe('"h-11 pointer-fine:h-8"\n"pointer-fine:h-9 focus-ring-within"');
|
||||||
|
expect(transform(once)).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves nothing to migrate in src", () => {
|
||||||
|
const files = sourceFiles(join(process.cwd(), "src"));
|
||||||
|
expect(files.length).toBeGreaterThan(100);
|
||||||
|
const pending = files.filter((file) => {
|
||||||
|
const text = readFileSync(file, "utf8");
|
||||||
|
return transform(text) !== text;
|
||||||
|
});
|
||||||
|
expect(pending).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function sourceFiles(dir) {
|
||||||
|
return readdirSync(dir).flatMap((name) => {
|
||||||
|
const path = join(dir, name);
|
||||||
|
if (statSync(path).isDirectory()) return sourceFiles(path);
|
||||||
|
return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* T5 motion codemod (spec 17.2, 17.4, 17.7, Task A11). Pure and idempotent:
|
||||||
|
*
|
||||||
|
* - `motion-safe:` only asks the system and skips the level chosen in the app. Movement behind it
|
||||||
|
* (translate, scale, rotate, slide-, zoom-, animate-, transition-transform, also behind further
|
||||||
|
* variants) moves to `motion-full:`.
|
||||||
|
* - Colour and opacity transitions need no variant ("Aus" sets every duration to zero):
|
||||||
|
* `motion-safe:transition-colors`/`-opacity` lose the variant.
|
||||||
|
* - Pulsing and spinning are full motion: a bare `animate-pulse`/`animate-spin` gets
|
||||||
|
* `motion-full:`, and `motion-reduce:animate-none` (the old opt-out) goes.
|
||||||
|
*
|
||||||
|
* Hover lifts, `transition-all` and overlay timings are changed by hand (they need a judgement).
|
||||||
|
*
|
||||||
|
* CLI: node scripts/codemod-t5-motion.mjs [--check] <file or directory…>
|
||||||
|
* --check lists the files that would change and exits 1 if there are any.
|
||||||
|
*/
|
||||||
|
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/** Characters that belong to a class token (so a match never starts inside one). */
|
||||||
|
const TOKEN_CHAR = String.raw`[\w:/\[\]().%-]`;
|
||||||
|
/** Further variants between the motion variant and the utility (`data-open:`, `group-hover:`). */
|
||||||
|
const VARIANTS = String.raw`(?:[^\s"'\`:]+:)*`;
|
||||||
|
const MOVEMENT = String.raw`-?(?:translate|scale|rotate|slide-|zoom-|animate-|transition-transform)`;
|
||||||
|
|
||||||
|
const SAFE_MOVEMENT = new RegExp(String.raw`(?<!${TOKEN_CHAR})motion-safe:(?=${VARIANTS}${MOVEMENT})`, "g");
|
||||||
|
const SAFE_FADE = new RegExp(String.raw`(?<!${TOKEN_CHAR})motion-safe:(?=transition-(?:colors|opacity)(?!${TOKEN_CHAR}))`, "g");
|
||||||
|
const REDUCE_NONE = new RegExp(String.raw` ?(?<!${TOKEN_CHAR})motion-reduce:animate-none(?!${TOKEN_CHAR})`, "g");
|
||||||
|
const BARE_PULSE_SPIN = new RegExp(String.raw`(?<!${TOKEN_CHAR})animate-(pulse|spin)(?![\w-])`, "g");
|
||||||
|
|
||||||
|
/** Rewrites the motion classes of one source text. */
|
||||||
|
export function transform(text) {
|
||||||
|
return text
|
||||||
|
.replace(SAFE_MOVEMENT, "motion-full:")
|
||||||
|
.replace(SAFE_FADE, "")
|
||||||
|
.replace(REDUCE_NONE, "")
|
||||||
|
.replace(BARE_PULSE_SPIN, "motion-full:animate-$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filesOf(path) {
|
||||||
|
if (statSync(path).isDirectory()) {
|
||||||
|
return readdirSync(path).flatMap((name) => filesOf(join(path, name)));
|
||||||
|
}
|
||||||
|
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const check = args.includes("--check");
|
||||||
|
const changed = args
|
||||||
|
.filter((arg) => arg !== "--check")
|
||||||
|
.flatMap((arg) => filesOf(resolve(arg)))
|
||||||
|
.filter((file) => {
|
||||||
|
const before = readFileSync(file, "utf8");
|
||||||
|
const after = transform(before);
|
||||||
|
if (after === before) return false;
|
||||||
|
if (!check) writeFileSync(file, after);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
for (const file of changed) console.log(file);
|
||||||
|
if (check && changed.length > 0) process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { transform } from "./codemod-t5-motion.mjs";
|
||||||
|
|
||||||
|
describe("codemod t5 motion (spec 17.2, 17.4, 17.7)", () => {
|
||||||
|
it("puts movement behind motion-full: instead of motion-safe:", () => {
|
||||||
|
expect(transform('cn("size-4 motion-safe:transition-transform", open && "rotate-180")')).toBe(
|
||||||
|
'cn("size-4 motion-full:transition-transform", open && "rotate-180")',
|
||||||
|
);
|
||||||
|
expect(transform('"holo-stage bg-holo-bg motion-safe:animate-pulse"')).toBe('"holo-stage bg-holo-bg motion-full:animate-pulse"');
|
||||||
|
expect(transform('" motion-safe:animate-spin"')).toBe('" motion-full:animate-spin"');
|
||||||
|
expect(transform('"motion-safe:data-open:zoom-in-95 motion-safe:slide-in-from-top-2"')).toBe(
|
||||||
|
'"motion-full:data-open:zoom-in-95 motion-full:slide-in-from-top-2"',
|
||||||
|
);
|
||||||
|
expect(transform('"motion-safe:-translate-y-1 motion-safe:scale-95 motion-safe:rotate-90"')).toBe(
|
||||||
|
'"motion-full:-translate-y-1 motion-full:scale-95 motion-full:rotate-90"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the variant from colour and opacity transitions (off sets their duration to zero)", () => {
|
||||||
|
expect(transform('"rounded-lg px-2 motion-safe:transition-colors hover:bg-muted"')).toBe(
|
||||||
|
'"rounded-lg px-2 transition-colors hover:bg-muted"',
|
||||||
|
);
|
||||||
|
expect(transform('"motion-safe:transition-opacity"')).toBe('"transition-opacity"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gates bare pulse and spin behind full motion and drops motion-reduce:animate-none", () => {
|
||||||
|
expect(transform('"size-4 animate-spin motion-reduce:animate-none"')).toBe('"size-4 motion-full:animate-spin"');
|
||||||
|
expect(transform('"h-14 animate-pulse rounded-xl"')).toBe('"h-14 motion-full:animate-pulse rounded-xl"');
|
||||||
|
expect(transform('"size-4 animate-spin"')).toBe('"size-4 motion-full:animate-spin"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves other animate classes, tokens and identifiers alone", () => {
|
||||||
|
const untouched = [
|
||||||
|
'"data-open:animate-in data-closed:animate-out"',
|
||||||
|
'"motion-full:animate-pulse"',
|
||||||
|
"const isAnimating = animate-spinner;",
|
||||||
|
'"animate-page-in"',
|
||||||
|
"// motion-safe: is forbidden for movement",
|
||||||
|
];
|
||||||
|
for (const text of untouched) expect(transform(text)).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent", () => {
|
||||||
|
const once = transform('"a motion-safe:transition-transform animate-spin motion-reduce:animate-none motion-safe:transition-colors"');
|
||||||
|
expect(transform(once)).toBe(once);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// App icons from the master mark (spec 12.2): `npm run icons`.
|
||||||
|
// Reads public/brand/apicore-mark.svg and writes the PNG/SVG/ICO files listed
|
||||||
|
// in ICON_FILES. Idempotent: the same input gives byte-identical files, and a
|
||||||
|
// file whose bytes would not change is not rewritten. The output is committed,
|
||||||
|
// so the Docker image never runs this script.
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const MASTER = "public/brand/apicore-mark.svg";
|
||||||
|
|
||||||
|
// Spec 12.1: dark ink oklch(0.24 0.035 55) behind the cell, light wax for shortcuts.
|
||||||
|
const INK = "#2c1b0f";
|
||||||
|
const WAX = "#f9d280";
|
||||||
|
const WHITE = "#ffffff";
|
||||||
|
|
||||||
|
// Every variant is drawn on a 512 grid; the 64 grid of the mark is scaled into it.
|
||||||
|
const GRID = 512;
|
||||||
|
const MARK_GRID = 64;
|
||||||
|
// Share of the edge the 64 box of the mark takes. "any": the cell is 70 % high.
|
||||||
|
// Maskable/monochrome: the cell (stroke included) stays inside the safe circle
|
||||||
|
// of 40 % radius and below 66 % of the edge (spec 12.2): 0.68 gives 32 % / 64 %.
|
||||||
|
const MARK_BOX = { any: 0.8, maskable: 0.68, favicon: 1 };
|
||||||
|
// Spec 12.2: "Ecken 22 % gerundet".
|
||||||
|
const ANY_CORNER_RADIUS = 0.22;
|
||||||
|
// Shortcut icons: the 24 px area icon takes half the edge, inside any launcher mask.
|
||||||
|
const SHORTCUT_ICON_SHARE = 0.5;
|
||||||
|
const LUCIDE_GRID = 24;
|
||||||
|
|
||||||
|
/** Shortcut icon per manifest shortcut: the lucide icon of its area (spec 11). */
|
||||||
|
export const SHORTCUT_ICONS = [
|
||||||
|
{ file: "public/icons/shortcut-inspection-96.png", area: "inspections", lucide: "clipboard-check" },
|
||||||
|
{ file: "public/icons/shortcut-varroa-96.png", area: "varroa", lucide: "bug" },
|
||||||
|
{ file: "public/icons/shortcut-today-96.png", area: "dashboard", lucide: "layout-dashboard" },
|
||||||
|
];
|
||||||
|
const SHORTCUT_SIZE = 96;
|
||||||
|
|
||||||
|
export const ICON_FILES = [
|
||||||
|
"public/icon.svg",
|
||||||
|
"public/icons/icon-192.png",
|
||||||
|
"public/icons/icon-512.png",
|
||||||
|
"public/icons/maskable-192.png",
|
||||||
|
"public/icons/maskable-512.png",
|
||||||
|
"public/icons/monochrome-512.png",
|
||||||
|
...SHORTCUT_ICONS.map((shortcut) => shortcut.file),
|
||||||
|
"src/app/apple-icon.png",
|
||||||
|
"src/app/favicon.ico",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FAVICON_SIZES = [16, 32, 48];
|
||||||
|
const PNG_OPTIONS = { compressionLevel: 9, adaptiveFiltering: true, palette: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `d` of the cell and the lid in the master SVG.
|
||||||
|
* @param {string} svg
|
||||||
|
* @returns {{ cell: string, lid: string }}
|
||||||
|
*/
|
||||||
|
export function readMasterPaths(svg) {
|
||||||
|
return { cell: pathElement(svg, "cell").d, lid: pathElement(svg, "lid").d };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} svg
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {{ element: string, d: string }}
|
||||||
|
*/
|
||||||
|
function pathElement(svg, id) {
|
||||||
|
const element = svg.match(new RegExp(`<path\\b[^>]*\\bid="${id}"[^>]*/>`))?.[0];
|
||||||
|
const d = element?.match(/\bd="([^"]+)"/)?.[1];
|
||||||
|
if (!element || !d) {
|
||||||
|
throw new Error(`${MASTER}: no <path id="${id}" d="…"/>`);
|
||||||
|
}
|
||||||
|
return { element, d };
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = (value) => Number(value.toFixed(4));
|
||||||
|
|
||||||
|
/** The mark scaled into the 512 grid, centred, taking `box` of the edge. */
|
||||||
|
function markGroup(content, box) {
|
||||||
|
const scale = round((box * GRID) / MARK_GRID);
|
||||||
|
const offset = round((GRID - box * GRID) / 2);
|
||||||
|
return `<g transform="translate(${offset} ${offset}) scale(${scale})">${content}</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgDocument(body, size) {
|
||||||
|
const dimensions = size ? ` width="${size}" height="${size}"` : "";
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg"${dimensions} viewBox="0 0 ${GRID} ${GRID}">${body}</svg>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SVG bodies of every mark variant, from the master. */
|
||||||
|
function variants(master) {
|
||||||
|
const cell = pathElement(master, "cell");
|
||||||
|
const lid = pathElement(master, "lid");
|
||||||
|
const withoutId = (element) => element.replace(/\sid="[^"]*"/, "");
|
||||||
|
const colouredMark = `${withoutId(cell.element)}${withoutId(lid.element)}`;
|
||||||
|
const silhouette = withoutId(cell.element)
|
||||||
|
.replace(/\bfill="[^"]*"/, `fill="${WHITE}"`)
|
||||||
|
.replace(/\bstroke="[^"]*"/, `stroke="${WHITE}"`);
|
||||||
|
const radius = round(ANY_CORNER_RADIUS * GRID);
|
||||||
|
return {
|
||||||
|
any: `<rect width="${GRID}" height="${GRID}" rx="${radius}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.any)}`,
|
||||||
|
apple: `<rect width="${GRID}" height="${GRID}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.any)}`,
|
||||||
|
maskable: `<rect width="${GRID}" height="${GRID}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.maskable)}`,
|
||||||
|
monochrome: markGroup(silhouette, MARK_BOX.maskable),
|
||||||
|
favicon: markGroup(colouredMark, MARK_BOX.favicon),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lucide icon node (`[tag, attrs][]`) of an icon name, from the installed lucide-react. */
|
||||||
|
async function lucideNode(name) {
|
||||||
|
const icon = await import(`lucide-react/dist/esm/icons/${name}.mjs`);
|
||||||
|
return icon.__iconNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lucideMarkup(node) {
|
||||||
|
return node
|
||||||
|
.map(([tag, attrs]) => {
|
||||||
|
const attributes = Object.entries(attrs)
|
||||||
|
.filter(([key]) => key !== "key")
|
||||||
|
.map(([key, value]) => `${key}="${value}"`)
|
||||||
|
.join(" ");
|
||||||
|
return `<${tag} ${attributes}/>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Area icon in ink on wax, full bleed (spec 12.2). */
|
||||||
|
function shortcutSvg(markup) {
|
||||||
|
const scale = round((SHORTCUT_ICON_SHARE * GRID) / LUCIDE_GRID);
|
||||||
|
const offset = round((GRID - SHORTCUT_ICON_SHARE * GRID) / 2);
|
||||||
|
return (
|
||||||
|
`<rect width="${GRID}" height="${GRID}" fill="${WAX}"/>` +
|
||||||
|
`<g transform="translate(${offset} ${offset}) scale(${scale})" fill="none" stroke="${INK}" ` +
|
||||||
|
`stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${markup}</g>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPng(body, size) {
|
||||||
|
return sharp(Buffer.from(svgDocument(body, size))).png(PNG_OPTIONS).toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICO container with PNG entries (sharp writes no ICO): ICONDIR, one
|
||||||
|
* ICONDIRENTRY per image, then the PNG data.
|
||||||
|
* @param {{ size: number, png: Buffer }[]} images
|
||||||
|
* @returns {Buffer}
|
||||||
|
*/
|
||||||
|
export function buildIco(images) {
|
||||||
|
const HEADER = 6;
|
||||||
|
const ENTRY = 16;
|
||||||
|
const header = Buffer.alloc(HEADER);
|
||||||
|
header.writeUInt16LE(0, 0);
|
||||||
|
header.writeUInt16LE(1, 2);
|
||||||
|
header.writeUInt16LE(images.length, 4);
|
||||||
|
let offset = HEADER + ENTRY * images.length;
|
||||||
|
const entries = images.map(({ size, png }) => {
|
||||||
|
const entry = Buffer.alloc(ENTRY);
|
||||||
|
// One byte each: 256 is written as 0.
|
||||||
|
entry.writeUInt8(size >= 256 ? 0 : size, 0);
|
||||||
|
entry.writeUInt8(size >= 256 ? 0 : size, 1);
|
||||||
|
entry.writeUInt8(0, 2);
|
||||||
|
entry.writeUInt8(0, 3);
|
||||||
|
entry.writeUInt16LE(1, 4);
|
||||||
|
entry.writeUInt16LE(32, 6);
|
||||||
|
entry.writeUInt32LE(png.length, 8);
|
||||||
|
entry.writeUInt32LE(offset, 12);
|
||||||
|
offset += png.length;
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
return Buffer.concat([header, ...entries, ...images.map((image) => image.png)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIfChanged(file, content) {
|
||||||
|
mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
let current = null;
|
||||||
|
try {
|
||||||
|
current = readFileSync(file);
|
||||||
|
} catch {
|
||||||
|
// Not there yet.
|
||||||
|
}
|
||||||
|
if (!current || !current.equals(content)) {
|
||||||
|
writeFileSync(file, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders every icon into `outRoot` (same relative paths as in the app).
|
||||||
|
* @param {{ root?: string, outRoot?: string }} [options]
|
||||||
|
* @returns {Promise<string[]>} the files, relative to `outRoot`
|
||||||
|
*/
|
||||||
|
export async function generateIcons({ root = ROOT, outRoot = root } = {}) {
|
||||||
|
const master = readFileSync(path.join(root, MASTER), "utf8");
|
||||||
|
const bodies = variants(master);
|
||||||
|
const outputs = new Map();
|
||||||
|
|
||||||
|
outputs.set("public/icon.svg", Buffer.from(svgDocument(bodies.any)));
|
||||||
|
for (const size of [192, 512]) {
|
||||||
|
outputs.set(`public/icons/icon-${size}.png`, await renderPng(bodies.any, size));
|
||||||
|
outputs.set(`public/icons/maskable-${size}.png`, await renderPng(bodies.maskable, size));
|
||||||
|
}
|
||||||
|
outputs.set("public/icons/monochrome-512.png", await renderPng(bodies.monochrome, 512));
|
||||||
|
for (const shortcut of SHORTCUT_ICONS) {
|
||||||
|
const markup = lucideMarkup(await lucideNode(shortcut.lucide));
|
||||||
|
outputs.set(shortcut.file, await renderPng(shortcutSvg(markup), SHORTCUT_SIZE));
|
||||||
|
}
|
||||||
|
outputs.set("src/app/apple-icon.png", await renderPng(bodies.apple, 180));
|
||||||
|
const favicons = [];
|
||||||
|
for (const size of FAVICON_SIZES) {
|
||||||
|
favicons.push({ size, png: await renderPng(bodies.favicon, size) });
|
||||||
|
}
|
||||||
|
outputs.set("src/app/favicon.ico", buildIco(favicons));
|
||||||
|
|
||||||
|
const files = ICON_FILES.filter((file) => outputs.has(file));
|
||||||
|
for (const file of files) {
|
||||||
|
writeIfChanged(path.join(outRoot, ...file.split("/")), outputs.get(file));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
|
if (isCli) {
|
||||||
|
generateIcons()
|
||||||
|
.then((files) => {
|
||||||
|
process.stdout.write(`${files.join("\n")}\n`);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { BRAND_CELL_PATHS } from "@/components/brand-mark";
|
||||||
|
import { AREAS } from "@/lib/areas";
|
||||||
|
import { ICON_FILES, SHORTCUT_ICONS, buildIco, generateIcons, readMasterPaths } from "./generate-icons.mjs";
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
|
||||||
|
describe("generate-icons (spec 12.2)", () => {
|
||||||
|
let outDir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
outDir = mkdtempSync(path.join(tmpdir(), "icons-"));
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(outDir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("the master SVG has the BrandMark geometry", () => {
|
||||||
|
expect(readMasterPaths(readFileSync(path.join(ROOT, "public/brand/apicore-mark.svg"), "utf8"))).toEqual(
|
||||||
|
BRAND_CELL_PATHS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the lid sits centred over the cell, only lifted", () => {
|
||||||
|
const xs = (d: string) => [...d.matchAll(/L([\d.]+)\s/g)].map((match) => Number(match[1]));
|
||||||
|
const lidXs = xs(BRAND_CELL_PATHS.lid);
|
||||||
|
expect(Math.min(...lidXs) + Math.max(...lidXs)).toBeCloseTo(64, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shortcut icons use the icon of their area", () => {
|
||||||
|
const pascal = (name: string) => name.replace(/(^|-)(\w)/g, (_, __, char: string) => char.toUpperCase());
|
||||||
|
for (const shortcut of SHORTCUT_ICONS) {
|
||||||
|
expect(AREAS[shortcut.area as keyof typeof AREAS].icon.displayName, shortcut.file).toBe(pascal(shortcut.lucide));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("running the icon script twice leaves the files byte-identical", async () => {
|
||||||
|
const first = await generateIcons({ root: ROOT, outRoot: outDir });
|
||||||
|
const snapshot = new Map(first.map((file) => [file, readFileSync(path.join(outDir, file))]));
|
||||||
|
const second = await generateIcons({ root: ROOT, outRoot: outDir });
|
||||||
|
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect([...first].sort()).toEqual([...ICON_FILES].sort());
|
||||||
|
for (const file of second) {
|
||||||
|
expect(readFileSync(path.join(outDir, file)).equals(snapshot.get(file) as Buffer), file).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildIco writes a header, one entry per PNG and the PNGs behind it", () => {
|
||||||
|
const png = (size: number) => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(size)]);
|
||||||
|
const ico = buildIco([
|
||||||
|
{ size: 16, png: png(10) },
|
||||||
|
{ size: 256, png: png(20) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect([ico.readUInt16LE(0), ico.readUInt16LE(2), ico.readUInt16LE(4)]).toEqual([0, 1, 2]);
|
||||||
|
// 256 px is written as 0 in the one-byte width and height fields.
|
||||||
|
expect([ico.readUInt8(6), ico.readUInt8(22)]).toEqual([16, 0]);
|
||||||
|
expect(ico.readUInt16LE(6 + 6)).toBe(32);
|
||||||
|
const secondOffset = ico.readUInt32LE(22 + 12);
|
||||||
|
expect(ico.readUInt32LE(6 + 12)).toBe(6 + 2 * 16);
|
||||||
|
expect(secondOffset).toBe(6 + 2 * 16 + 14);
|
||||||
|
expect(ico.length).toBe(secondOffset + 24);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,7 +78,7 @@ export function ColonyOverviewLinks({ overview, hrefFor }: ColonyOverviewLinksPr
|
|||||||
<Link
|
<Link
|
||||||
key={row.id}
|
key={row.id}
|
||||||
href={hrefFor(row.id)}
|
href={hrefFor(row.id)}
|
||||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
@@ -126,8 +126,8 @@ export function WeatherCard({
|
|||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-lg border p-2.5 text-sm ${
|
className={`flex items-center gap-2 rounded-lg border p-2.5 text-sm ${
|
||||||
todaySuitability
|
todaySuitability
|
||||||
? "border-primary/30 bg-primary/5 text-primary"
|
? "border-primary/30 bg-primary/5 text-primary-ink"
|
||||||
: "border-warning/40 bg-warning/10 text-warning-foreground"
|
: "border-warning/40 bg-warning/10 text-warning-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{todaySuitability ? (
|
{todaySuitability ? (
|
||||||
@@ -155,7 +155,7 @@ export function WeatherCard({
|
|||||||
{day.tempMinC !== null ? `${Math.round(day.tempMinC)}°` : "–"}
|
{day.tempMinC !== null ? `${Math.round(day.tempMinC)}°` : "–"}
|
||||||
</span>
|
</span>
|
||||||
{day.precipitationMm !== null && day.precipitationMm > 0 ? (
|
{day.precipitationMm !== null && day.precipitationMm > 0 ? (
|
||||||
<span className="text-xs text-primary">
|
<span className="text-xs text-primary-ink">
|
||||||
{day.precipitationMm.toFixed(0)} mm
|
{day.precipitationMm.toFixed(0)} mm
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -35,7 +35,25 @@ export default function ApiariesPage() {
|
|||||||
);
|
);
|
||||||
const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE");
|
const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE");
|
||||||
const historyApiaries = byLocation.filter((apiary) => apiary.status !== "ACTIVE");
|
const historyApiaries = byLocation.filter((apiary) => apiary.status !== "ACTIVE");
|
||||||
const visibleApiaries = tab === "active" ? activeApiaries : historyApiaries;
|
|
||||||
|
const listOf = (bucket: EntityLifecycleTab) => {
|
||||||
|
const apiaries = bucket === "active" ? activeApiaries : historyApiaries;
|
||||||
|
return (
|
||||||
|
<QueryState query={apiariesQuery} what="Bienenstände">
|
||||||
|
{() =>
|
||||||
|
apiaries.length === 0 ? (
|
||||||
|
<ApiariesEmpty
|
||||||
|
tab={bucket}
|
||||||
|
isFiltered={location !== ""}
|
||||||
|
onResetFilter={() => setLocation("")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ApiaryGrid apiaries={apiaries} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
@@ -63,13 +81,12 @@ export default function ApiariesPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<ActiveHistoryTabs
|
<ActiveHistoryTabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onValueChange={setTab}
|
||||||
activeCount={activeApiaries.length}
|
activeCount={activeApiaries.length}
|
||||||
historyCount={historyApiaries.length}
|
historyCount={historyApiaries.length}
|
||||||
/>
|
toolbar={
|
||||||
<Input
|
<Input
|
||||||
className="w-48"
|
className="w-48"
|
||||||
placeholder="Standort"
|
placeholder="Standort"
|
||||||
@@ -77,21 +94,10 @@ export default function ApiariesPage() {
|
|||||||
value={location}
|
value={location}
|
||||||
onChange={(event) => setLocation(event.target.value)}
|
onChange={(event) => setLocation(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<QueryState query={apiariesQuery} what="Bienenstände">
|
|
||||||
{() =>
|
|
||||||
visibleApiaries.length === 0 ? (
|
|
||||||
<ApiariesEmpty
|
|
||||||
tab={tab}
|
|
||||||
isFiltered={location !== ""}
|
|
||||||
onResetFilter={() => setLocation("")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ApiaryGrid apiaries={visibleApiaries} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</QueryState>
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,4 +281,30 @@ describe("PrintLabelsCard", () => {
|
|||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Berechtigung/);
|
expect(await screen.findByRole("alert")).toHaveTextContent(/Berechtigung/);
|
||||||
});
|
});
|
||||||
|
it("label print start position uses NumberStepper", async () => {
|
||||||
|
renderCard();
|
||||||
|
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
|
||||||
|
const input = screen.getByLabelText(/Startposition/);
|
||||||
|
expect(input).toHaveValue("1");
|
||||||
|
expect(input).toHaveAttribute("inputmode", "numeric");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Eine Position weiter/ }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Eine Position weiter/ }));
|
||||||
|
expect(input).toHaveValue("3");
|
||||||
|
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
|
||||||
|
await userEvent.click(main);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(printLabels).toHaveBeenCalledWith("tpl-1", "b-1", { startPosition: 3, proof: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("caps the start position at the server limit of 1000 when the template has no sheet size", async () => {
|
||||||
|
vi.mocked(fetchLabelTemplates).mockResolvedValue([{ ...TEMPLATE, columns: null, rows: null }]);
|
||||||
|
renderCard();
|
||||||
|
await screen.findByRole("button", { name: "Etiketten als PDF" });
|
||||||
|
const input = screen.getByLabelText(/Startposition/);
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, "5000");
|
||||||
|
|
||||||
|
expect(input).toHaveValue("1000");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -17,16 +17,19 @@ import { PARTICULAR_LABELS } from "@/components/labels/label-compliance-panel";
|
|||||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { NumberStepper } from "@/components/form/number-stepper";
|
||||||
import { RecordSelect } from "@/components/form/record-select";
|
import { RecordSelect } from "@/components/form/record-select";
|
||||||
|
|
||||||
|
/** Highest start position the server takes (label-templates.controller.ts). */
|
||||||
|
const MAX_START_POSITION = 1000;
|
||||||
|
|
||||||
const PRINT_FALLBACK = "Etiketten konnten nicht erstellt werden.";
|
const PRINT_FALLBACK = "Etiketten konnten nicht erstellt werden.";
|
||||||
const PRINT_DONE = "Etiketten-PDF heruntergeladen.";
|
const PRINT_DONE = "Etiketten-PDF heruntergeladen.";
|
||||||
const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht.";
|
const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht.";
|
||||||
const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
|
const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
|
||||||
const LINK_CLASS =
|
const LINK_CLASS =
|
||||||
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring";
|
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary-ink underline underline-offset-4";
|
||||||
|
|
||||||
type FixTarget = "master" | "product" | "designer";
|
type FixTarget = "master" | "product" | "designer";
|
||||||
|
|
||||||
@@ -138,8 +141,10 @@ export function PrintLabelsCard({
|
|||||||
const canCheck = canReadTemplates && hasPermission("bottling:read");
|
const canCheck = canReadTemplates && hasPermission("bottling:read");
|
||||||
const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly();
|
const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly();
|
||||||
const reasonId = useId();
|
const reasonId = useId();
|
||||||
|
const startHintId = useId();
|
||||||
const [templateId, setTemplateId] = useState("");
|
const [templateId, setTemplateId] = useState("");
|
||||||
const [startPosition, setStartPosition] = useState(1);
|
// "" while the field is emptied: printing then starts at the first label.
|
||||||
|
const [startPositionText, setStartPositionText] = useState("1");
|
||||||
const [serverGaps, setServerGaps] = useState<ServerGaps | null>(null);
|
const [serverGaps, setServerGaps] = useState<ServerGaps | null>(null);
|
||||||
const [printError, setPrintError] = useState<string | null>(null);
|
const [printError, setPrintError] = useState<string | null>(null);
|
||||||
const [printDone, setPrintDone] = useState<string | null>(null);
|
const [printDone, setPrintDone] = useState<string | null>(null);
|
||||||
@@ -160,6 +165,7 @@ export function PrintLabelsCard({
|
|||||||
selectedTemplate?.columns && selectedTemplate?.rows
|
selectedTemplate?.columns && selectedTemplate?.rows
|
||||||
? selectedTemplate.columns * selectedTemplate.rows
|
? selectedTemplate.columns * selectedTemplate.rows
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const startPosition = Math.max(1, Number(startPositionText) || 1);
|
||||||
|
|
||||||
// The answer holds the food business address: no persistence, dropped as
|
// The answer holds the food business address: no persistence, dropped as
|
||||||
// soon as nobody looks at it any more.
|
// soon as nobody looks at it any more.
|
||||||
@@ -301,7 +307,7 @@ export function PrintLabelsCard({
|
|||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{warnings.map((warning, index) => (
|
{warnings.map((warning, index) => (
|
||||||
<li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm">
|
<li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm">
|
||||||
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning" />
|
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning-ink" />
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium">Hinweis:</span> {warning.title}
|
<span className="font-medium">Hinweis:</span> {warning.title}
|
||||||
</span>
|
</span>
|
||||||
@@ -314,16 +320,18 @@ export function PrintLabelsCard({
|
|||||||
<Label htmlFor="print-start-position">
|
<Label htmlFor="print-start-position">
|
||||||
Startposition auf dem Bogen (1 = oben links)
|
Startposition auf dem Bogen (1 = oben links)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<NumberStepper
|
||||||
id="print-start-position"
|
id="print-start-position"
|
||||||
type="number"
|
describedBy={startHintId}
|
||||||
inputMode="numeric"
|
value={startPositionText}
|
||||||
|
onChange={setStartPositionText}
|
||||||
min={1}
|
min={1}
|
||||||
max={slotsPerSheet}
|
max={slotsPerSheet ?? MAX_START_POSITION}
|
||||||
value={startPosition}
|
unitLabel={slotsPerSheet ? `von ${slotsPerSheet}` : "Position"}
|
||||||
onChange={(event) => setStartPosition(Math.max(1, Number(event.target.value)))}
|
decrementLabel="Eine Position zurück"
|
||||||
|
incrementLabel="Eine Position weiter"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p id={startHintId} className="text-xs text-muted-foreground">
|
||||||
Für bereits angebrochene Bögen: Position der ersten noch freien Etikette.
|
Für bereits angebrochene Bögen: Position der ersten noch freien Etikette.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -452,14 +452,14 @@ export default function NewBottlingBatchPage() {
|
|||||||
/>
|
/>
|
||||||
{withdrawalCheckFailed ? (
|
{withdrawalCheckFailed ? (
|
||||||
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||||
Wartezeit konnte nicht geprüft werden
|
Wartezeit konnte nicht geprüft werden
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{withdrawalHints.length > 0 ? (
|
{withdrawalHints.length > 0 ? (
|
||||||
<div className="flex flex-col gap-1.5 rounded-lg bg-warning/15 p-3 text-sm">
|
<div className="flex flex-col gap-1.5 rounded-lg bg-warning/15 p-3 text-sm">
|
||||||
<p className="flex items-center gap-2 font-medium">
|
<p className="flex items-center gap-2 font-medium">
|
||||||
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
|
<AlertTriangle className="size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||||
Honig aus einer Wartezeit
|
Honig aus einer Wartezeit
|
||||||
</p>
|
</p>
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function BottomNav({ user }: { user: Pick<AuthUser, "permissions"> }) {
|
|||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
|
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
|
||||||
isActive ? "text-primary" : "text-muted-foreground",
|
isActive ? "text-primary-ink" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-5" aria-hidden="true" />
|
<Icon className="size-5" aria-hidden="true" />
|
||||||
|
|||||||
@@ -192,6 +192,20 @@ describe("CalendarPage entries", () => {
|
|||||||
expect(link).toHaveTextContent("Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig");
|
expect(link).toHaveTextContent("Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks the chosen day with an inset ring and keeps the focus ring inside the clipped grid", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" });
|
||||||
|
const day = screen.getByRole("button", { name: /Dienstag, 11\. August/ });
|
||||||
|
await userEvent.click(day);
|
||||||
|
|
||||||
|
const classes = day.className.split(/\s+/);
|
||||||
|
expect(classes).toEqual(expect.arrayContaining(["ring-2", "ring-inset", "ring-foreground"]));
|
||||||
|
// A selection outline would replace the focus ring; the grid clips (overflow-hidden), so
|
||||||
|
// the focus ring of every day is drawn inside the cell, next to the selection ring.
|
||||||
|
expect(day.className).not.toMatch(/(^|\s)outline-(2|foreground)|-outline-offset-2/);
|
||||||
|
expect(classes).toContain("focus-visible:outline-offset-[calc(-2px_-_var(--focus-width))]");
|
||||||
|
});
|
||||||
|
|
||||||
it("counts a day with several entries in the plural", async () => {
|
it("counts a day with several entries in the plural", async () => {
|
||||||
vi.mocked(fetchCalendarEvents).mockResolvedValue([
|
vi.mocked(fetchCalendarEvents).mockResolvedValue([
|
||||||
{ id: "a", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "A", relatedType: "Task", relatedId: "1" },
|
{ id: "a", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "A", relatedType: "Task", relatedId: "1" },
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ function EventRow({ event, isContinuation }: { event: CalendarEvent; isContinuat
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
return href ? (
|
return href ? (
|
||||||
<Link href={href} className="block rounded-lg focus-visible:ring-3 focus-visible:ring-ring/50">
|
<Link href={href} className="block rounded-lg">
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@@ -353,9 +353,11 @@ export default function CalendarPage() {
|
|||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${entryCountText(dayEvents.length)}`}
|
aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${entryCountText(dayEvents.length)}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0",
|
// The grid clips (overflow-hidden): the focus ring sits inside the cell,
|
||||||
|
// next to the inset selection ring, so edge days keep it whole.
|
||||||
|
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0 focus-visible:outline-offset-[calc(-2px_-_var(--focus-width))]",
|
||||||
isCurrentMonth ? "bg-card" : "bg-muted/40",
|
isCurrentMonth ? "bg-card" : "bg-muted/40",
|
||||||
isSelected && "outline-2 -outline-offset-2 outline-primary",
|
isSelected && "ring-2 ring-inset ring-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function ActionFooter({
|
|||||||
ref={errorRef}
|
ref={errorRef}
|
||||||
role="alert"
|
role="alert"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="text-sm font-medium text-destructive outline-none"
|
className="text-sm font-medium text-destructive"
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
|||||||
ref={conflictRef}
|
ref={conflictRef}
|
||||||
role="alert"
|
role="alert"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="text-sm font-medium text-destructive outline-none"
|
className="text-sm font-medium text-destructive"
|
||||||
>
|
>
|
||||||
{conflictText}
|
{conflictText}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ function VarroaTrendChart({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||||
className="w-full text-primary"
|
className="w-full text-primary-ink"
|
||||||
role="img"
|
role="img"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-describedby={descId}
|
aria-describedby={descId}
|
||||||
@@ -293,7 +293,7 @@ function ChartLegend({ hasRed, hasYellow }: { hasRed: boolean; hasYellow: boolea
|
|||||||
return (
|
return (
|
||||||
<ul aria-label="Legende" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
<ul aria-label="Legende" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
<li className="flex items-center gap-1.5">
|
<li className="flex items-center gap-1.5">
|
||||||
<span className="text-primary">
|
<span className="text-primary-ink">
|
||||||
<LegendSwatch width={2} />
|
<LegendSwatch width={2} />
|
||||||
</span>
|
</span>
|
||||||
<span>Milbenfall pro Tag</span>
|
<span>Milbenfall pro Tag</span>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function HiveEditorPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!colony.data || !hive.data || !catalog) {
|
if (!colony.data || !hive.data || !catalog) {
|
||||||
return <div role="status" aria-label="Beute wird geladen" className="fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
|
return <div role="status" aria-label="Beute wird geladen" className="holo-stage fixed inset-0 z-40 bg-holo-bg motion-full:animate-pulse" />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<HiveEditor
|
<HiveEditor
|
||||||
|
|||||||
@@ -55,4 +55,20 @@ describe("SwarmControlEditDialog", () => {
|
|||||||
notes: null,
|
notes: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it("swarm control edit counts queen cells with NumberStepper", async () => {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<SwarmControlEditDialog check={check} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
|
||||||
|
await screen.findByRole("dialog");
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/^Weiselzellen gefunden/)).toHaveValue("3");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Eine Weiselzelle weniger/ }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
|
expect(vi.mocked(updateSwarmControlCheck).mock.calls[0][1]).toMatchObject({ queenCellsFound: 2 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -12,8 +12,9 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { EnumSelect } from "@/components/form/enum-select";
|
import { EnumSelect } from "@/components/form/enum-select";
|
||||||
import { ColonySelectField } from "@/components/colony-select-field";
|
import { ColonySelectField } from "@/components/colony-select-field";
|
||||||
import { Field } from "@/components/form/field";
|
import { Field } from "@/components/form/field";
|
||||||
|
import { NumberStepper } from "@/components/form/number-stepper";
|
||||||
import { FormSection } from "@/components/form/form-section";
|
import { FormSection } from "@/components/form/form-section";
|
||||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -115,22 +116,22 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
|||||||
<FormSection step={2} title="Befund">
|
<FormSection step={2} title="Befund">
|
||||||
<Field label="Weiselzellen gefunden" hint="Anzahl">
|
<Field label="Weiselzellen gefunden" hint="Anzahl">
|
||||||
{({ id, describedBy }) => (
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<NumberStepper
|
||||||
id={id}
|
id={id}
|
||||||
type="number"
|
describedBy={describedBy}
|
||||||
inputMode="numeric"
|
|
||||||
min={0}
|
|
||||||
className="h-11"
|
|
||||||
aria-describedby={describedBy}
|
|
||||||
value={queenCellsFound}
|
value={queenCellsFound}
|
||||||
onChange={(event) => setQueenCellsFound(event.target.value)}
|
onChange={setQueenCellsFound}
|
||||||
|
unitLabel="Weiselzellen"
|
||||||
|
decrementLabel="Eine Weiselzelle weniger"
|
||||||
|
incrementLabel="Eine Weiselzelle mehr"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<ToggleChip
|
<CheckboxField
|
||||||
|
appearance="tile"
|
||||||
label="Weiselzellen bereits verdeckelt (akut)"
|
label="Weiselzellen bereits verdeckelt (akut)"
|
||||||
checked={queenCellsCapped}
|
checked={queenCellsCapped}
|
||||||
onChange={setQueenCellsCapped}
|
onCheckedChange={setQueenCellsCapped}
|
||||||
/>
|
/>
|
||||||
<Field label="Schwarmstimmung" required>
|
<Field label="Schwarmstimmung" required>
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
|
|||||||
@@ -135,4 +135,15 @@ describe("VarroaEditDialog", () => {
|
|||||||
await openDialog();
|
await openDialog();
|
||||||
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
|
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
it("the varroa edit dialog counts mites with NumberStepper", async () => {
|
||||||
|
await openDialog();
|
||||||
|
|
||||||
|
const miteCount = screen.getByLabelText(/^Milbenzahl/);
|
||||||
|
expect(miteCount).toHaveValue("12");
|
||||||
|
expect(miteCount).toHaveAttribute("inputmode", "numeric");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
|
expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ miteCount: 13 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { EnumSelect } from "@/components/form/enum-select";
|
import { EnumSelect } from "@/components/form/enum-select";
|
||||||
import { ColonySelectField } from "@/components/colony-select-field";
|
import { ColonySelectField } from "@/components/colony-select-field";
|
||||||
import { Field } from "@/components/form/field";
|
import { Field } from "@/components/form/field";
|
||||||
|
import { NumberStepper } from "@/components/form/number-stepper";
|
||||||
import { FormSection } from "@/components/form/form-section";
|
import { FormSection } from "@/components/form/form-section";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -146,17 +147,18 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
{isNaturalFall ? (
|
{isNaturalFall ? (
|
||||||
<VarroaPeriodField minutes={durationMinutes} onChange={setDurationMinutes} />
|
<VarroaPeriodField minutes={durationMinutes} onChange={setDurationMinutes} />
|
||||||
) : null}
|
) : null}
|
||||||
<div className={isNaturalFall ? undefined : "grid grid-cols-2 gap-3"}>
|
{/* One column: the stepper needs the width of the dialog on a phone. */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<Field label="Milbenzahl">
|
<Field label="Milbenzahl">
|
||||||
{({ id }) => (
|
{({ id, describedBy }) => (
|
||||||
<Input
|
<NumberStepper
|
||||||
id={id}
|
id={id}
|
||||||
type="number"
|
describedBy={describedBy}
|
||||||
inputMode="numeric"
|
|
||||||
min={0}
|
|
||||||
className="h-11"
|
|
||||||
value={miteCount}
|
value={miteCount}
|
||||||
onChange={(event) => setMiteCount(event.target.value)}
|
onChange={setMiteCount}
|
||||||
|
unitLabel="Milben"
|
||||||
|
decrementLabel="Eine Milbe weniger"
|
||||||
|
incrementLabel="Eine Milbe mehr"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { createColony } from "@/lib/colonies/api";
|
import { createColony } from "@/lib/colonies/api";
|
||||||
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
||||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
@@ -239,7 +238,7 @@ export default function NewColonyPage() {
|
|||||||
ref={errorRef}
|
ref={errorRef}
|
||||||
role="alert"
|
role="alert"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="text-sm font-medium text-destructive outline-none"
|
className="text-sm font-medium text-destructive"
|
||||||
>
|
>
|
||||||
{submitError}
|
{submitError}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -48,10 +48,30 @@ export default function ColoniesPage() {
|
|||||||
const colonies = coloniesQuery.data ?? [];
|
const colonies = coloniesQuery.data ?? [];
|
||||||
const activeColonies = colonies.filter((colony) => colony.deactivatedAt === null);
|
const activeColonies = colonies.filter((colony) => colony.deactivatedAt === null);
|
||||||
const historyColonies = colonies.filter((colony) => colony.deactivatedAt !== null);
|
const historyColonies = colonies.filter((colony) => colony.deactivatedAt !== null);
|
||||||
const bucketColonies = tab === "active" ? activeColonies : historyColonies;
|
|
||||||
const visibleColonies = status ? bucketColonies.filter((colony) => colony.status === status) : bucketColonies;
|
|
||||||
const statusOptions = tab === "active" ? LIVING_COLONY_STATUSES : ENDED_COLONY_STATUSES;
|
const statusOptions = tab === "active" ? LIVING_COLONY_STATUSES : ENDED_COLONY_STATUSES;
|
||||||
|
|
||||||
|
const listOf = (bucket: EntityLifecycleTab) => {
|
||||||
|
const bucketColonies = bucket === "active" ? activeColonies : historyColonies;
|
||||||
|
const visibleColonies = status
|
||||||
|
? bucketColonies.filter((colony) => colony.status === status)
|
||||||
|
: bucketColonies;
|
||||||
|
return (
|
||||||
|
<QueryState query={coloniesQuery} what="Völker">
|
||||||
|
{() =>
|
||||||
|
visibleColonies.length === 0 ? (
|
||||||
|
<ColoniesEmpty
|
||||||
|
tab={bucket}
|
||||||
|
isFiltered={status !== "" && bucketColonies.length > 0}
|
||||||
|
onResetFilter={() => setStatus("")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ColonyGrid colonies={visibleColonies} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -82,16 +102,15 @@ export default function ColoniesPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<ActiveHistoryTabs
|
<ActiveHistoryTabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={(next) => {
|
onValueChange={(next) => {
|
||||||
setTab(next);
|
setTab(next);
|
||||||
setStatus("");
|
setStatus("");
|
||||||
}}
|
}}
|
||||||
activeCount={activeColonies.length}
|
activeCount={activeColonies.length}
|
||||||
historyCount={historyColonies.length}
|
historyCount={historyColonies.length}
|
||||||
/>
|
toolbar={
|
||||||
<EnumSelect
|
<EnumSelect
|
||||||
ariaLabel="Nach Status filtern"
|
ariaLabel="Nach Status filtern"
|
||||||
className="w-40"
|
className="w-40"
|
||||||
@@ -101,21 +120,10 @@ export default function ColoniesPage() {
|
|||||||
labels={COLONY_STATUS_LABELS}
|
labels={COLONY_STATUS_LABELS}
|
||||||
noneLabel="Alle Status"
|
noneLabel="Alle Status"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<QueryState query={coloniesQuery} what="Völker">
|
|
||||||
{() =>
|
|
||||||
visibleColonies.length === 0 ? (
|
|
||||||
<ColoniesEmpty
|
|
||||||
tab={tab}
|
|
||||||
isFiltered={status !== "" && bucketColonies.length > 0}
|
|
||||||
onResetFilter={() => setStatus("")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ColonyGrid colonies={visibleColonies} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
</QueryState>
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ function Step({ number, title, titleId, state, children }: StepProps) {
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
aria-current={state === "current" ? "step" : undefined}
|
aria-current={state === "current" ? "step" : undefined}
|
||||||
className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-primary aria-[current=step]:bg-primary/10"
|
className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-foreground aria-[current=step]:bg-primary/10"
|
||||||
>
|
>
|
||||||
<span className="flex flex-wrap items-center justify-between gap-2">
|
<span className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
id={titleId}
|
id={titleId}
|
||||||
tabIndex={titleId ? -1 : undefined}
|
tabIndex={titleId ? -1 : undefined}
|
||||||
className="font-heading text-base font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="font-heading text-base font-semibold"
|
||||||
>{`${number}. ${title}`}</span>
|
>{`${number}. ${title}`}</span>
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium">
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium">
|
||||||
<Icon className="size-4" aria-hidden />
|
<Icon className="size-4" aria-hidden />
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export default function NoticeDetailPage() {
|
|||||||
id="notice-flow-heading"
|
id="notice-flow-heading"
|
||||||
ref={flowHeading}
|
ref={flowHeading}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="font-heading text-lg font-semibold tracking-tight outline-none"
|
className="font-heading text-lg font-semibold tracking-tight"
|
||||||
>
|
>
|
||||||
{flowTitle}
|
{flowTitle}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { CreateNoticeDialog } from "./create-notice-dialog";
|
import { CreateNoticeDialog } from "./create-notice-dialog";
|
||||||
@@ -97,4 +97,14 @@ describe("CreateNoticeDialog", () => {
|
|||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("Erst die Erstanzeige erfassen.");
|
expect(await screen.findByRole("alert")).toHaveTextContent("Erst die Erstanzeige erfassen.");
|
||||||
});
|
});
|
||||||
|
it("offers the kind of notice as cards with their hint", async () => {
|
||||||
|
renderDialog();
|
||||||
|
await open();
|
||||||
|
|
||||||
|
const group = screen.getByRole("radiogroup", { name: "Art der Anzeige" });
|
||||||
|
const first = within(group).getByRole("radio", { name: "Erstanzeige" });
|
||||||
|
expect(first).toHaveAttribute("aria-checked", "true");
|
||||||
|
expect(first).toHaveAccessibleDescription("Deine erste Meldung an das Veterinäramt.");
|
||||||
|
expect(first).toHaveClass("min-h-14");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
import { FormEvent, useEffect, useId, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||||
|
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||||
import { FormSection } from "@/components/form/form-section";
|
import { FormSection } from "@/components/form/form-section";
|
||||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -28,6 +29,12 @@ const KIND_HINT: Record<AuthorityNoticeKind, string> = {
|
|||||||
TSK_REPORT: "Die jährliche Zählung für die Tierseuchenkasse.",
|
TSK_REPORT: "Die jährliche Zählung für die Tierseuchenkasse.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const KIND_OPTIONS: readonly ChoiceOption<AuthorityNoticeKind>[] = NOTICE_KINDS.map((kind) => ({
|
||||||
|
value: kind,
|
||||||
|
label: NOTICE_KIND_LABEL[kind],
|
||||||
|
description: KIND_HINT[kind],
|
||||||
|
}));
|
||||||
|
|
||||||
interface CreateNoticeDialogProps {
|
interface CreateNoticeDialogProps {
|
||||||
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
|
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -75,6 +82,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const attempt = useAttemptKey();
|
const attempt = useAttemptKey();
|
||||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||||
|
const kindLabelId = useId();
|
||||||
const [kind, setKind] = useState<AuthorityNoticeKind>("REGISTRATION");
|
const [kind, setKind] = useState<AuthorityNoticeKind>("REGISTRATION");
|
||||||
const [year, setYear] = useState(String(new Date().getFullYear()));
|
const [year, setYear] = useState(String(new Date().getFullYear()));
|
||||||
const [alreadySent, setAlreadySent] = useState(false);
|
const [alreadySent, setAlreadySent] = useState(false);
|
||||||
@@ -131,34 +139,18 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<legend className="mb-1 text-sm font-medium">Art der Anzeige</legend>
|
<p id={kindLabelId} className="text-base font-medium">
|
||||||
{NOTICE_KINDS.map((entry) => (
|
Art der Anzeige
|
||||||
<label
|
</p>
|
||||||
key={entry}
|
<ChoiceGroup
|
||||||
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border px-3 py-2 has-[:checked]:border-primary has-[:checked]:bg-primary/10"
|
options={KIND_OPTIONS}
|
||||||
>
|
value={kind}
|
||||||
<input
|
onChange={setKind}
|
||||||
type="radio"
|
labelledBy={kindLabelId}
|
||||||
name="notice-kind"
|
appearance="cards"
|
||||||
value={entry}
|
|
||||||
checked={kind === entry}
|
|
||||||
onChange={() => setKind(entry)}
|
|
||||||
aria-labelledby={`notice-kind-${entry}`}
|
|
||||||
aria-describedby={`notice-kind-hint-${entry}`}
|
|
||||||
className="mt-1 size-4"
|
|
||||||
/>
|
/>
|
||||||
<span className="flex flex-col">
|
</div>
|
||||||
<span id={`notice-kind-${entry}`} className="text-sm font-medium">
|
|
||||||
{NOTICE_KIND_LABEL[entry]}
|
|
||||||
</span>
|
|
||||||
<span id={`notice-kind-hint-${entry}`} className="text-xs text-muted-foreground">
|
|
||||||
{KIND_HINT[entry]}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
{kind === "TSK_REPORT" ? (
|
{kind === "TSK_REPORT" ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -173,7 +165,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ToggleChip label="Bereits angezeigt" checked={alreadySent} onChange={setAlreadySent} />
|
<CheckboxField appearance="tile" label="Bereits angezeigt" checked={alreadySent} onCheckedChange={setAlreadySent} />
|
||||||
{alreadySent ? (
|
{alreadySent ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="notice-sent-on">Angezeigt am</Label>
|
<Label htmlFor="notice-sent-on">Angezeigt am</Label>
|
||||||
@@ -197,7 +189,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
|||||||
<p aria-live="polite" className="min-h-5 text-sm text-destructive">
|
<p aria-live="polite" className="min-h-5 text-sm text-destructive">
|
||||||
{showProblem ? problem : null}
|
{showProblem ? problem : null}
|
||||||
</p>
|
</p>
|
||||||
<div ref={errorRef} tabIndex={-1} role="alert" className="outline-none">
|
<div ref={errorRef} tabIndex={-1} role="alert">
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex flex-col gap-2 text-sm text-destructive">
|
<div className="flex flex-col gap-2 text-sm text-destructive">
|
||||||
<p>{error.text}</p>
|
<p>{error.text}</p>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function NoticesPage() {
|
|||||||
id="notice-kind-filter"
|
id="notice-kind-filter"
|
||||||
value={kind}
|
value={kind}
|
||||||
onChange={(event) => setKind(event.target.value as KindFilter)}
|
onChange={(event) => setKind(event.target.value as KindFilter)}
|
||||||
className="h-11 w-full rounded-xl border border-input bg-card px-3 text-base sm:max-w-xs md:text-sm"
|
className="h-11 w-full rounded-xl border border-input bg-card px-3 text-base sm:max-w-xs"
|
||||||
>
|
>
|
||||||
<option value="ALL">Alle</option>
|
<option value="ALL">Alle</option>
|
||||||
{NOTICE_KINDS.map((entry) => (
|
{NOTICE_KINDS.map((entry) => (
|
||||||
@@ -85,7 +85,7 @@ export default function NoticesPage() {
|
|||||||
aria-invalid={yearInvalid ? true : undefined}
|
aria-invalid={yearInvalid ? true : undefined}
|
||||||
aria-describedby={yearInvalid ? "notice-year-problem" : undefined}
|
aria-describedby={yearInvalid ? "notice-year-problem" : undefined}
|
||||||
onChange={(event) => setYearText(event.target.value)}
|
onChange={(event) => setYearText(event.target.value)}
|
||||||
className="h-11 w-28 rounded-xl border border-input bg-card px-3 text-base md:text-sm"
|
className="h-11 w-28 rounded-xl border border-input bg-card px-3 text-base"
|
||||||
/>
|
/>
|
||||||
{yearInvalid ? (
|
{yearInvalid ? (
|
||||||
<p id="notice-year-problem" className="text-sm text-destructive">
|
<p id="notice-year-problem" className="text-sm text-destructive">
|
||||||
@@ -131,7 +131,7 @@ function NoticeLink({ notice }: { notice: AuthorityNotice }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/compliance/notices/${encodeURIComponent(notice.id)}`}
|
href={`/compliance/notices/${encodeURIComponent(notice.id)}`}
|
||||||
className="flex min-h-11 flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="flex min-h-11 flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<span className="flex flex-wrap items-center justify-between gap-2">
|
<span className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="font-heading text-base font-semibold">{noticeTitle(notice)}</span>
|
<span className="font-heading text-base font-semibold">{noticeTitle(notice)}</span>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const STATE_ICON: Record<ReadinessItemState, LucideIcon> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FIX_LINK_CLASS =
|
const FIX_LINK_CLASS =
|
||||||
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50";
|
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary-ink underline-offset-4 hover:underline";
|
||||||
|
|
||||||
function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) {
|
function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) {
|
||||||
const { href, label, available } = fixLinkFor(itemKey, subject);
|
const { href, label, available } = fixLinkFor(itemKey, subject);
|
||||||
@@ -117,7 +117,7 @@ export function ReadinessItemCard({ item, canManage }: ReadinessItemCardProps) {
|
|||||||
<h3
|
<h3
|
||||||
ref={headingRef}
|
ref={headingRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="min-w-0 font-heading text-base font-semibold break-words outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="min-w-0 font-heading text-base font-semibold break-words"
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
|||||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||||
import { Field } from "@/components/form/field";
|
import { Field } from "@/components/form/field";
|
||||||
import { FormSection } from "@/components/form/form-section";
|
import { FormSection } from "@/components/form/form-section";
|
||||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -207,11 +207,12 @@ export function CreateRestrictionDialog() {
|
|||||||
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
|
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
|
||||||
>
|
>
|
||||||
{apiaries.map((apiary) => (
|
{apiaries.map((apiary) => (
|
||||||
<ToggleChip
|
<CheckboxField
|
||||||
|
appearance="tile"
|
||||||
key={apiary.id}
|
key={apiary.id}
|
||||||
label={apiary.name}
|
label={apiary.name}
|
||||||
checked={values.apiaryIds.includes(apiary.id)}
|
checked={values.apiaryIds.includes(apiary.id)}
|
||||||
onChange={(checked) => toggleApiary(apiary.id, checked)}
|
onCheckedChange={(checked) => toggleApiary(apiary.id, checked)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||||
import { DetailHeader } from "@/components/detail-header";
|
import { DetailHeader } from "@/components/detail-header";
|
||||||
import { StandNote } from "@/components/offline/stand-note";
|
import { StandNote } from "@/components/offline/stand-note";
|
||||||
@@ -47,7 +46,7 @@ function Section({
|
|||||||
const headingId = `restrictions-${id}`;
|
const headingId = `restrictions-${id}`;
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby={headingId} className="flex flex-col gap-2">
|
<section aria-labelledby={headingId} className="flex flex-col gap-2">
|
||||||
<h2 id={headingId} tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight outline-none">
|
<h2 id={headingId} tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight">
|
||||||
{`${title} (${rows.length})`}
|
{`${title} (${rows.length})`}
|
||||||
</h2>
|
</h2>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { settingsFixture } from "@/lib/compliance/notice-fixtures";
|
|||||||
import { fetchComplianceSettings, updateComplianceSettings } from "@/lib/compliance/settings-api";
|
import { fetchComplianceSettings, updateComplianceSettings } from "@/lib/compliance/settings-api";
|
||||||
|
|
||||||
const ASSUMPTION = "Annahme – bitte prüfen";
|
const ASSUMPTION = "Annahme – bitte prüfen";
|
||||||
|
const READ_ONLY_TEXT = "Nur ansehen – Ändern braucht das Recht, Meldungen zu verwalten.";
|
||||||
|
|
||||||
let permissions = ["compliance:read", "compliance:manage"];
|
let permissions = ["compliance:read", "compliance:manage"];
|
||||||
vi.mock("@/lib/auth/auth-context", () => ({
|
vi.mock("@/lib/auth/auth-context", () => ({
|
||||||
@@ -250,6 +251,26 @@ describe("ComplianceSettingsPage", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Speichern" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Speichern" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("read-only tiles say why they are off (hint text, not grey only)", async () => {
|
||||||
|
permissions = ["compliance:read"];
|
||||||
|
renderPage();
|
||||||
|
const trigger = await screen.findByRole("checkbox", { name: "Bei geänderter Völkerzahl" });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
|
||||||
|
const counted = screen.getByRole("checkbox", { name: "Ableger" });
|
||||||
|
|
||||||
|
// Both trigger tiles and the counted colony types show the reason next to them.
|
||||||
|
expect(screen.getAllByText(READ_ONLY_TEXT)).toHaveLength(3);
|
||||||
|
for (const hint of screen.getAllByText(READ_ONLY_TEXT)) expect(hint).toBeVisible();
|
||||||
|
expect(trigger).toHaveAccessibleDescription(expect.stringContaining(READ_ONLY_TEXT));
|
||||||
|
expect(counted).toHaveAccessibleDescription(READ_ONLY_TEXT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("editable tiles show no read-only hint", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByRole("checkbox", { name: "Bei geänderter Völkerzahl" });
|
||||||
|
expect(screen.queryByText(READ_ONLY_TEXT)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("says so without the read permission and loads nothing", () => {
|
it("says so without the read permission and loads nothing", () => {
|
||||||
permissions = [];
|
permissions = [];
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||||
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||||
import { FormSection } from "@/components/form/form-section";
|
import { FormSection } from "@/components/form/form-section";
|
||||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -32,6 +31,8 @@ import {
|
|||||||
} from "./digit-rules-field";
|
} from "./digit-rules-field";
|
||||||
|
|
||||||
const ASSUMPTION = "Annahme – bitte prüfen";
|
const ASSUMPTION = "Annahme – bitte prüfen";
|
||||||
|
/** Why a read-only tile is off (spec 10.3: hint text, not grey only). */
|
||||||
|
const READ_ONLY_HINT = "Nur ansehen – Ändern braucht das Recht, Meldungen zu verwalten.";
|
||||||
|
|
||||||
/** Input id per form field that can carry a validation error, in page order. */
|
/** Input id per form field that can carry a validation error, in page order. */
|
||||||
const FIELD_INPUT_ID: Readonly<Record<string, string>> = {
|
const FIELD_INPUT_ID: Readonly<Record<string, string>> = {
|
||||||
@@ -278,22 +279,26 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
|||||||
|
|
||||||
<FormSection title="Wann soll ich dich an eine Anzeige erinnern?" step={2}>
|
<FormSection title="Wann soll ich dich an eine Anzeige erinnern?" step={2}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<ToggleChip
|
<CheckboxField
|
||||||
|
appearance="tile"
|
||||||
label="Bei geänderter Völkerzahl"
|
label="Bei geänderter Völkerzahl"
|
||||||
checked={values.noticeOnColonyCountChange}
|
checked={values.noticeOnColonyCountChange}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
|
disabledHint={READ_ONLY_HINT}
|
||||||
describedBy="note-trigger-count"
|
describedBy="note-trigger-count"
|
||||||
onChange={(checked) => set("noticeOnColonyCountChange", checked)}
|
onCheckedChange={(checked) => set("noticeOnColonyCountChange", checked)}
|
||||||
/>
|
/>
|
||||||
<AssumptionNote id="note-trigger-count" />
|
<AssumptionNote id="note-trigger-count" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<ToggleChip
|
<CheckboxField
|
||||||
|
appearance="tile"
|
||||||
label="Bei geändertem Stand"
|
label="Bei geändertem Stand"
|
||||||
checked={values.noticeOnApiaryChange}
|
checked={values.noticeOnApiaryChange}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
|
disabledHint={READ_ONLY_HINT}
|
||||||
describedBy="note-trigger-apiary"
|
describedBy="note-trigger-apiary"
|
||||||
onChange={(checked) => set("noticeOnApiaryChange", checked)}
|
onCheckedChange={(checked) => set("noticeOnApiaryChange", checked)}
|
||||||
/>
|
/>
|
||||||
<AssumptionNote id="note-trigger-apiary" />
|
<AssumptionNote id="note-trigger-apiary" />
|
||||||
</div>
|
</div>
|
||||||
@@ -362,15 +367,22 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
|||||||
<p className="text-xs text-muted-foreground">Nur diese Arten zählen in der TSK-Zählung mit.</p>
|
<p className="text-xs text-muted-foreground">Nur diese Arten zählen in der TSK-Zählung mit.</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{COLONY_TYPES.map((type) => (
|
{COLONY_TYPES.map((type) => (
|
||||||
<ToggleChip
|
<CheckboxField
|
||||||
|
appearance="tile"
|
||||||
key={type}
|
key={type}
|
||||||
label={COLONY_TYPE_LABELS[type]}
|
label={COLONY_TYPE_LABELS[type]}
|
||||||
checked={values.counted.includes(type)}
|
checked={values.counted.includes(type)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
onChange={(checked) => toggleCounted(type, checked)}
|
describedBy={readOnly ? "note-counted-read-only" : undefined}
|
||||||
|
onCheckedChange={(checked) => toggleCounted(type, checked)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{readOnly ? (
|
||||||
|
<p id="note-counted-read-only" className="text-sm font-medium text-muted-foreground">
|
||||||
|
{READ_ONLY_HINT}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<DigitRulesField
|
<DigitRulesField
|
||||||
rows={values.rules}
|
rows={values.rules}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, render, screen, within } from "@testing-library/react";
|
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import DashboardPage from "./page";
|
import DashboardPage from "./page";
|
||||||
import { fetchAutopilotToday } from "@/lib/autopilot/api";
|
import { fetchAutopilotToday } from "@/lib/autopilot/api";
|
||||||
import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures";
|
import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures";
|
||||||
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
||||||
import { fetchTasks } from "@/lib/tasks/api";
|
import { completeTask, fetchTasks } from "@/lib/tasks/api";
|
||||||
import { fetchColonies } from "@/lib/colonies/api";
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||||
@@ -229,6 +230,27 @@ describe("DashboardPage – Heute", () => {
|
|||||||
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ticking a task on the Base UI honey-check cell completes it (Space works too)", async () => {
|
||||||
|
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
||||||
|
// The first task is the next step; the second one is a row with the tick cell.
|
||||||
|
vi.mocked(fetchTasks).mockResolvedValue([
|
||||||
|
overdueTask(),
|
||||||
|
{ ...overdueTask(), id: "t2", title: "Zweite Aufgabe" },
|
||||||
|
]);
|
||||||
|
vi.mocked(completeTask).mockResolvedValue({ ...overdueTask(), status: "DONE" } as never);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const cell = await screen.findByRole("checkbox", { name: "„Zweite Aufgabe“ als erledigt markieren" });
|
||||||
|
expect(cell).toHaveAttribute("data-slot", "checkbox");
|
||||||
|
expect(cell).toHaveClass("honey-check");
|
||||||
|
expect(cell).not.toBeChecked();
|
||||||
|
cell.focus();
|
||||||
|
await userEvent.keyboard(" ");
|
||||||
|
|
||||||
|
expect(cell).toBeChecked();
|
||||||
|
await waitFor(() => expect(completeTask).toHaveBeenCalledWith("t2"), { timeout: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
it("gives the dashboard text links a 44 px target", async () => {
|
it("gives the dashboard text links a 44 px target", async () => {
|
||||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export default function DashboardPage() {
|
|||||||
</span>
|
</span>
|
||||||
{href ? (
|
{href ? (
|
||||||
<Link
|
<Link
|
||||||
className="inline-flex min-h-11 shrink-0 items-center rounded-lg px-2 font-medium text-primary underline-offset-4 hover:underline"
|
className="inline-flex min-h-11 shrink-0 items-center rounded-lg px-2 font-medium text-primary-ink underline-offset-4 hover:underline"
|
||||||
href={href}
|
href={href}
|
||||||
>
|
>
|
||||||
Ansehen
|
Ansehen
|
||||||
@@ -244,10 +244,10 @@ export default function DashboardPage() {
|
|||||||
<li key={tile.label}>
|
<li key={tile.label}>
|
||||||
<Link
|
<Link
|
||||||
href={tile.href}
|
href={tile.href}
|
||||||
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
|
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className={`size-5 shrink-0 ${tile.attention ? "text-warning" : "text-primary"}`}
|
className={`size-5 shrink-0 ${tile.attention ? "text-warning-ink" : "text-primary-ink"}`}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<span className="font-heading text-lg font-semibold tabular-nums">
|
<span className="font-heading text-lg font-semibold tabular-nums">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
|
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { createEquipment } from "@/lib/equipment/api";
|
import { createEquipment } from "@/lib/equipment/api";
|
||||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||||
import { errorMessage } from "@/lib/forms/error-message";
|
import { errorMessage } from "@/lib/forms/error-message";
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchExtraction } from "@/lib/extractions/api";
|
import { fetchExtraction } from "@/lib/extractions/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { fetchHarvests } from "@/lib/harvests/api";
|
import { fetchHarvests } from "@/lib/harvests/api";
|
||||||
import {
|
import {
|
||||||
createExtraction,
|
createExtraction,
|
||||||
@@ -25,7 +24,7 @@ import { PageHeader } from "@/components/page-header";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Field } from "@/components/form/field";
|
import { Field } from "@/components/form/field";
|
||||||
import { FormSection } from "@/components/form/form-section";
|
import { FormSection } from "@/components/form/form-section";
|
||||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||||
import { DraftBanner } from "@/components/form/draft-banner";
|
import { DraftBanner } from "@/components/form/draft-banner";
|
||||||
import {
|
import {
|
||||||
@@ -203,16 +202,17 @@ export default function NewExtractionPage() {
|
|||||||
data-field="harvestIds"
|
data-field="harvestIds"
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
className="flex flex-col gap-2 outline-none"
|
className="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
{harvests?.map((harvest) => (
|
{harvests?.map((harvest) => (
|
||||||
<ToggleChip
|
<CheckboxField
|
||||||
|
appearance="tile"
|
||||||
key={harvest.id}
|
key={harvest.id}
|
||||||
label={`${new Date(harvest.date).toLocaleDateString("de-DE")} · ${harvest.colonies
|
label={`${new Date(harvest.date).toLocaleDateString("de-DE")} · ${harvest.colonies
|
||||||
.map((entry) => entry.colony.code)
|
.map((entry) => entry.colony.code)
|
||||||
.join(", ")}`}
|
.join(", ")}`}
|
||||||
checked={values.harvestIds.includes(harvest.id)}
|
checked={values.harvestIds.includes(harvest.id)}
|
||||||
onChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
toggleHarvest(harvest.id, checked)
|
toggleHarvest(harvest.id, checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { createFeeding } from "@/lib/feedings/api";
|
import { createFeeding } from "@/lib/feedings/api";
|
||||||
import { createFeedingsBulk } from "@/lib/bulk/api";
|
import { createFeedingsBulk } from "@/lib/bulk/api";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Field } from "@/components/form/field";
|
import { Field } from "@/components/form/field";
|
||||||
import { FormSection } from "@/components/form/form-section";
|
import { FormSection } from "@/components/form/form-section";
|
||||||
import { RecordSelect } from "@/components/form/record-select";
|
import { RecordSelect } from "@/components/form/record-select";
|
||||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||||
import {
|
import {
|
||||||
DATE_REQUIRED_MESSAGE,
|
DATE_REQUIRED_MESSAGE,
|
||||||
DialogSubmitBar,
|
DialogSubmitBar,
|
||||||
@@ -177,11 +177,12 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
|||||||
className="grid grid-cols-2 gap-2"
|
className="grid grid-cols-2 gap-2"
|
||||||
>
|
>
|
||||||
{selectableColonies.map((colony) => (
|
{selectableColonies.map((colony) => (
|
||||||
<ToggleChip
|
<CheckboxField
|
||||||
|
appearance="tile"
|
||||||
key={colony.id}
|
key={colony.id}
|
||||||
label={colony.code}
|
label={colony.code}
|
||||||
checked={colonyIds.includes(colony.id)}
|
checked={colonyIds.includes(colony.id)}
|
||||||
onChange={(checked) => toggleColony(colony.id, checked)}
|
onCheckedChange={(checked) => toggleColony(colony.id, checked)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchHarvest } from "@/lib/harvests/api";
|
import { fetchHarvest } from "@/lib/harvests/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { PageHeader } from "@/components/page-header";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Field } from "@/components/form/field";
|
import { Field } from "@/components/form/field";
|
||||||
import { FormSection } from "@/components/form/form-section";
|
import { FormSection } from "@/components/form/form-section";
|
||||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||||
import { DraftBanner } from "@/components/form/draft-banner";
|
import { DraftBanner } from "@/components/form/draft-banner";
|
||||||
import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice";
|
import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice";
|
||||||
@@ -253,16 +253,17 @@ export default function NewHarvestPage() {
|
|||||||
data-field="colonyIds"
|
data-field="colonyIds"
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
className="grid grid-cols-2 gap-2 outline-none"
|
className="grid grid-cols-2 gap-2"
|
||||||
>
|
>
|
||||||
{apiaryColonies.map((colony) => {
|
{apiaryColonies.map((colony) => {
|
||||||
const withdrawal = withdrawalByColony.get(colony.id);
|
const withdrawal = withdrawalByColony.get(colony.id);
|
||||||
return (
|
return (
|
||||||
<div key={colony.id} className="flex flex-col gap-1">
|
<div key={colony.id} className="flex flex-col gap-1">
|
||||||
<ToggleChip
|
<CheckboxField
|
||||||
|
appearance="tile"
|
||||||
label={colonyOptionLabel(colony)}
|
label={colonyOptionLabel(colony)}
|
||||||
checked={values.colonyIds.includes(colony.id)}
|
checked={values.colonyIds.includes(colony.id)}
|
||||||
onChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
toggleColony(colony.id, checked)
|
toggleColony(colony.id, checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -284,7 +285,7 @@ export default function NewHarvestPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
{withdrawalQuery.isError ? (
|
{withdrawalQuery.isError ? (
|
||||||
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||||
Wartezeiten konnten nicht geprüft werden. Vor der Ernte die Behandlungen der
|
Wartezeiten konnten nicht geprüft werden. Vor der Ernte die Behandlungen der
|
||||||
Völker selbst ansehen.
|
Völker selbst ansehen.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function MovementList({ movements }: { movements: HoneyBatchMovement[] }) {
|
|||||||
className={
|
className={
|
||||||
movement.quantityGrams < 0
|
movement.quantityGrams < 0
|
||||||
? "font-semibold tabular-nums text-destructive"
|
? "font-semibold tabular-nums text-destructive"
|
||||||
: "font-semibold tabular-nums text-primary"
|
: "font-semibold tabular-nums text-primary-ink"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{formatSignedKg(gramsToKg(movement.quantityGrams))}
|
{formatSignedKg(gramsToKg(movement.quantityGrams))}
|
||||||
|
|||||||
@@ -42,7 +42,34 @@ export default function HoneyBatchesPage() {
|
|||||||
const honeyBatches = honeyBatchesQuery.data ?? [];
|
const honeyBatches = honeyBatchesQuery.data ?? [];
|
||||||
const activeBatches = honeyBatches.filter((batch) => !HISTORY_STATUSES.includes(batch.status));
|
const activeBatches = honeyBatches.filter((batch) => !HISTORY_STATUSES.includes(batch.status));
|
||||||
const historyBatches = honeyBatches.filter((batch) => HISTORY_STATUSES.includes(batch.status));
|
const historyBatches = honeyBatches.filter((batch) => HISTORY_STATUSES.includes(batch.status));
|
||||||
const visibleBatches = tab === "active" ? activeBatches : historyBatches;
|
|
||||||
|
const listOf = (bucket: EntityLifecycleTab) => {
|
||||||
|
const batches = bucket === "active" ? activeBatches : historyBatches;
|
||||||
|
return (
|
||||||
|
<QueryState query={honeyBatchesQuery} what="Honigchargen">
|
||||||
|
{() =>
|
||||||
|
batches.length === 0 ? (
|
||||||
|
bucket === "active" ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Droplet}
|
||||||
|
title="Noch keine Honigchargen"
|
||||||
|
description="Lege eine Charge an, sobald du geschleudert hast."
|
||||||
|
action={<CreateHoneyBatchDialog />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Droplet}
|
||||||
|
title="Keine Historie"
|
||||||
|
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<HoneyBatchGrid honeyBatches={batches} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
@@ -74,33 +101,12 @@ export default function HoneyBatchesPage() {
|
|||||||
|
|
||||||
<ActiveHistoryTabs
|
<ActiveHistoryTabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onValueChange={setTab}
|
||||||
activeCount={activeBatches.length}
|
activeCount={activeBatches.length}
|
||||||
historyCount={historyBatches.length}
|
historyCount={historyBatches.length}
|
||||||
/>
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
<QueryState query={honeyBatchesQuery} what="Honigchargen">
|
</ActiveHistoryTabs>
|
||||||
{() =>
|
|
||||||
visibleBatches.length === 0 ? (
|
|
||||||
tab === "active" ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={Droplet}
|
|
||||||
title="Noch keine Honigchargen"
|
|
||||||
description="Lege eine Charge an, sobald du geschleudert hast."
|
|
||||||
action={<CreateHoneyBatchDialog />}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={Droplet}
|
|
||||||
title="Keine Historie"
|
|
||||||
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<HoneyBatchGrid honeyBatches={visibleBatches} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</QueryState>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ function HiveOpsList({ ops, base, catalog, onChange, onUndone, onEmptied }: Hive
|
|||||||
</div>
|
</div>
|
||||||
{blocked?.index === index ? (
|
{blocked?.index === index ? (
|
||||||
<p role="alert" className="flex items-start gap-2 text-sm">
|
<p role="alert" className="flex items-start gap-2 text-sm">
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||||
{blocked.reason} Mach zuerst die späteren Änderungen rückgängig.
|
{blocked.reason} Mach zuerst die späteren Änderungen rückgängig.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -147,7 +147,7 @@ export function InspectionHiveSection({
|
|||||||
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
|
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
|
||||||
<div
|
<div
|
||||||
data-testid="hive-section"
|
data-testid="hive-section"
|
||||||
className="flex flex-col gap-4 outline-none"
|
className="flex flex-col gap-4"
|
||||||
onSubmit={(event) => event.stopPropagation()}
|
onSubmit={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 ref={headingRef} tabIndex={-1} className="sr-only">
|
<h3 ref={headingRef} tabIndex={-1} className="sr-only">
|
||||||
|
|||||||
@@ -734,7 +734,7 @@ function InspectionCaptureForm({
|
|||||||
{noQueenSigns ? (
|
{noQueenSigns ? (
|
||||||
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
||||||
<AlertTriangle
|
<AlertTriangle
|
||||||
className="mt-0.5 size-4 shrink-0 text-warning"
|
className="mt-0.5 size-4 shrink-0 text-warning-ink"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
Weder Königin noch Stifte gesehen. Nach dem Speichern wird
|
Weder Königin noch Stifte gesehen. Nach dem Speichern wird
|
||||||
@@ -894,7 +894,7 @@ function InspectionCaptureForm({
|
|||||||
/>
|
/>
|
||||||
{values.feedLegacyText !== null ? (
|
{values.feedLegacyText !== null ? (
|
||||||
<p role="status" className="flex items-start gap-2 text-sm">
|
<p role="status" className="flex items-start gap-2 text-sm">
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||||
{feedLegacyHint(values.feedLegacyText)}
|
{feedLegacyHint(values.feedLegacyText)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -63,12 +63,14 @@ vi.mock("@/components/offline/offline-band", () => ({
|
|||||||
userId,
|
userId,
|
||||||
pageContentRef,
|
pageContentRef,
|
||||||
syncEnabled,
|
syncEnabled,
|
||||||
|
inert,
|
||||||
}: {
|
}: {
|
||||||
userId: string;
|
userId: string;
|
||||||
pageContentRef: { current: HTMLElement | null };
|
pageContentRef: { current: HTMLElement | null };
|
||||||
syncEnabled?: boolean;
|
syncEnabled?: boolean;
|
||||||
|
inert?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<div data-testid="offline-band" data-sync-enabled={String(syncEnabled)}>
|
<div data-testid="offline-band" data-sync-enabled={String(syncEnabled)} inert={inert}>
|
||||||
{userId}
|
{userId}
|
||||||
<button type="button" onClick={() => pageContentRef.current?.focus()}>
|
<button type="button" onClick={() => pageContentRef.current?.focus()}>
|
||||||
Fokus auf den Seiteninhalt
|
Fokus auf den Seiteninhalt
|
||||||
@@ -92,6 +94,15 @@ vi.mock("@/lib/photos/store", () => ({
|
|||||||
// and since the page itself scrolls on the window, the bar would never stick.
|
// and since the page itself scrolls on the window, the bar would never stick.
|
||||||
const SCROLL_CONTAINER_CLASS = /(^|\s)([a-z]+:)*overflow-(x-|y-)?(auto|scroll|hidden)(\s|$)/;
|
const SCROLL_CONTAINER_CLASS = /(^|\s)([a-z]+:)*overflow-(x-|y-)?(auto|scroll|hidden)(\s|$)/;
|
||||||
|
|
||||||
|
const TABBABLE = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
/** Tab stops in document order as a browser sees them: jsdom and user-event ignore `inert`. */
|
||||||
|
function tabStops(): HTMLElement[] {
|
||||||
|
return Array.from(document.querySelectorAll<HTMLElement>(TABBABLE)).filter(
|
||||||
|
(element) => element.closest("[inert]") === null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function expectNoScrollContainerAbove(element: HTMLElement) {
|
function expectNoScrollContainerAbove(element: HTMLElement) {
|
||||||
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
||||||
expect(ancestor.className).not.toMatch(SCROLL_CONTAINER_CLASS);
|
expect(ancestor.className).not.toMatch(SCROLL_CONTAINER_CLASS);
|
||||||
@@ -127,6 +138,47 @@ describe("AppLayout", () => {
|
|||||||
expect(content).toHaveFocus();
|
expect(content).toHaveFocus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides the whole shell from the keyboard and screen readers behind the full-screen Beute editor", () => {
|
||||||
|
pathname = "/colonies/c1/hive";
|
||||||
|
permissions = ["inspections:create"];
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<main>
|
||||||
|
<button type="button">Zurück</button>
|
||||||
|
<h1>Umbauen</h1>
|
||||||
|
</main>
|
||||||
|
</AppLayout>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const back = screen.getByRole("button", { name: "Zurück" });
|
||||||
|
// The first Tab lands in the editor, not on 33 links of the sidebar and header behind it.
|
||||||
|
expect(tabStops()[0]).toBe(back);
|
||||||
|
expect(back.closest("[inert]")).toBeNull();
|
||||||
|
for (const shellPart of [
|
||||||
|
screen.getByText("Zum Inhalt springen"),
|
||||||
|
screen.getAllByRole("link", { name: /Dashboard|Start/ })[0],
|
||||||
|
screen.getByRole("button", { name: "Konto-Menü" }),
|
||||||
|
screen.getByTestId("offline-band"),
|
||||||
|
...document.querySelectorAll<HTMLElement>("aside, header, nav"),
|
||||||
|
]) {
|
||||||
|
expect(shellPart.closest("[inert]"), shellPart.outerHTML.slice(0, 60)).not.toBeNull();
|
||||||
|
}
|
||||||
|
// The phone's „+ Neu" would float over the editor: it is not there at all.
|
||||||
|
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the shell reachable on every other page", () => {
|
||||||
|
pathname = "/colonies/c1";
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<p>Seiteninhalt</p>
|
||||||
|
</AppLayout>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.querySelector("[inert]")).toBeNull();
|
||||||
|
expect(tabStops()[0]).toBe(screen.getByRole("link", { name: "Zum Inhalt springen" }));
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => {
|
it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => {
|
||||||
pathname = "/suggestions";
|
pathname = "/suggestions";
|
||||||
permissions = ["inspections:create"];
|
permissions = ["inspections:create"];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
|||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { useAppearance } from "@/lib/appearance/appearance";
|
import { useAppearance } from "@/lib/appearance/appearance";
|
||||||
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
|
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
|
||||||
|
import { AppMotion } from "@/components/app-motion";
|
||||||
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
|
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
|
||||||
import { BrandMark } from "@/components/brand-mark";
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
import { NotificationBell } from "@/components/notification-bell";
|
import { NotificationBell } from "@/components/notification-bell";
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Sidebar } from "./sidebar";
|
import { Sidebar } from "./sidebar";
|
||||||
import { BottomNav } from "./bottom-nav";
|
import { BottomNav } from "./bottom-nav";
|
||||||
|
import { isFullScreenPage } from "./nav-config";
|
||||||
import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
|
import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
|
||||||
import { PageTransition } from "./page-transition";
|
import { PageTransition } from "./page-transition";
|
||||||
|
|
||||||
@@ -72,26 +74,39 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
? "pb-[calc(var(--bottom-nav-height)+--spacing(18))]"
|
? "pb-[calc(var(--bottom-nav-height)+--spacing(18))]"
|
||||||
: "pb-(--bottom-nav-height)";
|
: "pb-(--bottom-nav-height)";
|
||||||
|
|
||||||
|
// A full-screen page (the Beute editor) covers the shell: everything of the
|
||||||
|
// shell turns inert, so the first Tab lands in the page and screen readers
|
||||||
|
// skip what cannot be seen. Decided by route, like the quick-add button:
|
||||||
|
// known on the first render and the same for the page's notice and loading
|
||||||
|
// states. `contents` wrappers keep the boxes where they are in the layout.
|
||||||
|
const isShellInert = isFullScreenPage(pathname);
|
||||||
|
|
||||||
// The page scrolls on the window. No ancestor of the page content may be a
|
// The page scrolls on the window. No ancestor of the page content may be a
|
||||||
// scroll container (overflow auto/scroll/hidden): it would capture
|
// scroll container (overflow auto/scroll/hidden): it would capture
|
||||||
// `position: sticky` and the StickySubmit save bar would never stick.
|
// `position: sticky` and the StickySubmit save bar would never stick.
|
||||||
return (
|
return (
|
||||||
|
<AppMotion>
|
||||||
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
|
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
|
||||||
<a
|
<a
|
||||||
href={`#${MAIN_CONTENT_ID}`}
|
href={`#${MAIN_CONTENT_ID}`}
|
||||||
|
inert={isShellInert}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
// The hash alone would not move the focus into a Next.js page reliably.
|
// The hash alone would not move the focus into a Next.js page reliably.
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
pageContentRef.current?.focus();
|
pageContentRef.current?.focus();
|
||||||
pageContentRef.current?.scrollIntoView?.({ block: "start" });
|
pageContentRef.current?.scrollIntoView?.({ block: "start" });
|
||||||
}}
|
}}
|
||||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)] focus:ring-3 focus:ring-ring/50 focus:outline-none"
|
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)]"
|
||||||
>
|
>
|
||||||
Zum Inhalt springen
|
Zum Inhalt springen
|
||||||
</a>
|
</a>
|
||||||
|
<div className="contents" inert={isShellInert}>
|
||||||
<Sidebar user={user} />
|
<Sidebar user={user} />
|
||||||
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
|
<header
|
||||||
|
inert={isShellInert}
|
||||||
|
className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="flex items-center font-heading font-semibold sm:hidden"
|
className="flex items-center font-heading font-semibold sm:hidden"
|
||||||
@@ -143,18 +158,21 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
userId={user.id}
|
userId={user.id}
|
||||||
pageContentRef={pageContentRef}
|
pageContentRef={pageContentRef}
|
||||||
syncEnabled={sessionMode === "online"}
|
syncEnabled={sessionMode === "online"}
|
||||||
|
inert={isShellInert}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
id={MAIN_CONTENT_ID}
|
id={MAIN_CONTENT_ID}
|
||||||
ref={pageContentRef}
|
ref={pageContentRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={`flex-1 ${contentPaddingClass} outline-none sm:pb-(--safe-area-bottom)`}
|
className={`flex-1 ${contentPaddingClass} sm:pb-(--safe-area-bottom)`}
|
||||||
>
|
>
|
||||||
<PageTransition>{children}</PageTransition>
|
<PageTransition>{children}</PageTransition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="contents" inert={isShellInert}>
|
||||||
<BottomNav user={user} />
|
<BottomNav user={user} />
|
||||||
<QuickAddSheet user={user} variant="fab" />
|
<QuickAddSheet user={user} variant="fab" />
|
||||||
|
</div>
|
||||||
<LogoutConfirmDialog
|
<LogoutConfirmDialog
|
||||||
open={isLogoutOpen}
|
open={isLogoutOpen}
|
||||||
onOpenChange={setIsLogoutOpen}
|
onOpenChange={setIsLogoutOpen}
|
||||||
@@ -162,5 +180,6 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
onLoggedOut={() => router.push("/login")}
|
onLoggedOut={() => router.push("/login")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</AppMotion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ interface LotFormValues {
|
|||||||
|
|
||||||
type LotFormErrors = Partial<Record<keyof LotFormValues, string>>;
|
type LotFormErrors = Partial<Record<keyof LotFormValues, string>>;
|
||||||
|
|
||||||
const INPUT_CLASS = "h-11 sm:h-11";
|
const INPUT_CLASS = "h-11 pointer-fine:h-11";
|
||||||
|
|
||||||
function initialValues(medicine: LotMedicine, lot?: LotView): LotFormValues {
|
function initialValues(medicine: LotMedicine, lot?: LotView): LotFormValues {
|
||||||
if (lot) {
|
if (lot) {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
|
|||||||
<Input
|
<Input
|
||||||
id="disposal-quantity"
|
id="disposal-quantity"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-11 sm:h-11"
|
className="h-11 pointer-fine:h-11"
|
||||||
value={quantityText}
|
value={quantityText}
|
||||||
onChange={(event) => setQuantityText(event.target.value)}
|
onChange={(event) => setQuantityText(event.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -141,7 +141,7 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="disposal-reason"
|
id="disposal-reason"
|
||||||
className="h-11 sm:h-11"
|
className="h-11 pointer-fine:h-11"
|
||||||
value={reason}
|
value={reason}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
placeholder="z. B. abgelaufen, verschüttet"
|
placeholder="z. B. abgelaufen, verschüttet"
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function InventoryForm({ lotId, remaining, unit, onDone }: InventoryFormProps) {
|
|||||||
<Input
|
<Input
|
||||||
id="inventory-counted"
|
id="inventory-counted"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-11 sm:h-11"
|
className="h-11 pointer-fine:h-11"
|
||||||
value={counted}
|
value={counted}
|
||||||
aria-describedby="inventory-status"
|
aria-describedby="inventory-status"
|
||||||
onChange={(event) => setCounted(event.target.value)}
|
onChange={(event) => setCounted(event.target.value)}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { buildBook } from "@/lib/medicines/book";
|
import { buildBook } from "@/lib/medicines/book";
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
|||||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||||
import { Field } from "@/components/form/field";
|
import { Field } from "@/components/form/field";
|
||||||
import { FormSection } from "@/components/form/form-section";
|
import { FormSection } from "@/components/form/form-section";
|
||||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -89,7 +89,7 @@ interface MedicineFormProps {
|
|||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT_CLASS = "h-11 sm:h-11";
|
const INPUT_CLASS = "h-11 pointer-fine:h-11";
|
||||||
|
|
||||||
function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
|
function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -219,10 +219,11 @@ function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<ToggleChip
|
<CheckboxField
|
||||||
|
appearance="tile"
|
||||||
label="Verschreibungspflichtig"
|
label="Verschreibungspflichtig"
|
||||||
checked={values.prescriptionOnly}
|
checked={values.prescriptionOnly}
|
||||||
onChange={(checked) => set("prescriptionOnly", checked)}
|
onCheckedChange={(checked) => set("prescriptionOnly", checked)}
|
||||||
/>
|
/>
|
||||||
<Field label="Anwendung" id="medicine-method" error={errorOf("applicationMethod")}>
|
<Field label="Anwendung" id="medicine-method" error={errorOf("applicationMethod")}>
|
||||||
{({ id, describedBy: fieldDescribedBy }) => (
|
{({ id, describedBy: fieldDescribedBy }) => (
|
||||||
|
|||||||
@@ -35,6 +35,22 @@ export default function MedicinesPage() {
|
|||||||
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
|
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const listOf = (bucket: EntityLifecycleTab) => {
|
||||||
|
const shown = bucket === "active" ? active : inactive;
|
||||||
|
if (shown.length > 0) return <MedicineGrid medicines={shown} />;
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={Pill}
|
||||||
|
title={bucket === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
||||||
|
description={
|
||||||
|
bucket === "active"
|
||||||
|
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
||||||
|
: "Deaktivierte Mittel erscheinen hier."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (!canRead) return <NoReadAccess />;
|
if (!canRead) return <NoReadAccess />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,35 +63,24 @@ export default function MedicinesPage() {
|
|||||||
/>
|
/>
|
||||||
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
||||||
|
|
||||||
{medicines.length > 0 ? (
|
|
||||||
<ActiveHistoryTabs
|
|
||||||
value={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
activeCount={active.length}
|
|
||||||
historyCount={inactive.length}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
|
|
||||||
{medicines.length > 0 ? countText : ""}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
||||||
{() =>
|
{() =>
|
||||||
medicines.length === 0 ? (
|
medicines.length === 0 ? (
|
||||||
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
|
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
|
||||||
) : visible.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={Pill}
|
|
||||||
title={tab === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
|
||||||
description={
|
|
||||||
tab === "active"
|
|
||||||
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
|
||||||
: "Deaktivierte Mittel erscheinen hier."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<MedicineGrid medicines={visible} />
|
<ActiveHistoryTabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={setTab}
|
||||||
|
activeCount={active.length}
|
||||||
|
historyCount={inactive.length}
|
||||||
|
toolbar={
|
||||||
|
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||||
|
{countText}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
|
|||||||
<SheetTrigger
|
<SheetTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
|
"flex flex-1 flex-col items-center justify-center gap-1 text-xs",
|
||||||
isMoreActive ? "text-primary" : "text-muted-foreground",
|
isMoreActive ? "text-primary-ink" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Menu className="size-5" aria-hidden="true" />
|
<Menu className="size-5" aria-hidden="true" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ADMIN_GROUP,
|
ADMIN_GROUP,
|
||||||
BOTTOM_NAV_HREFS,
|
BOTTOM_NAV_HREFS,
|
||||||
NAV_GROUPS,
|
NAV_GROUPS,
|
||||||
|
isFullScreenPage,
|
||||||
visibleNavGroups,
|
visibleNavGroups,
|
||||||
} from "./nav-config";
|
} from "./nav-config";
|
||||||
|
|
||||||
@@ -93,4 +94,13 @@ describe("nav-config", () => {
|
|||||||
expect(group?.label).toBe("Betrieb");
|
expect(group?.label).toBe("Betrieb");
|
||||||
expect(group?.items.find((item) => item.href === "/compliance")?.label).toBe("Prüfbereitschaft");
|
expect(group?.items.find((item) => item.href === "/compliance")?.label).toBe("Prüfbereitschaft");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("knows the Beute editor („Umbauen“) as the one full-screen page", () => {
|
||||||
|
expect(isFullScreenPage("/colonies/c1/hive")).toBe(true);
|
||||||
|
expect(isFullScreenPage("/colonies/c%201/hive/")).toBe(true);
|
||||||
|
expect(isFullScreenPage("/colonies/c1")).toBe(false);
|
||||||
|
expect(isFullScreenPage("/colonies")).toBe(false);
|
||||||
|
expect(isFullScreenPage("/colonies/c1/hive/history")).toBe(false);
|
||||||
|
expect(isFullScreenPage("/apiaries/a1/hive")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -226,3 +226,15 @@ export function bottomNavItems(): NavItem[] {
|
|||||||
export function isNavActive(pathname: string, href: string): boolean {
|
export function isNavActive(pathname: string, href: string): boolean {
|
||||||
return pathname === href || pathname.startsWith(`${href}/`);
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// „Umbauen" (spec 9.5): its own route, drawn over the whole screen inside the shell.
|
||||||
|
const FULL_SCREEN_PAGE = /^\/colonies\/[^/]+\/hive\/?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the page covers the whole screen. The shell behind it (skip link,
|
||||||
|
* sidebar, header, offline band, bottom nav) then turns inert, so the first
|
||||||
|
* Tab lands in the page and screen readers do not read what cannot be seen.
|
||||||
|
*/
|
||||||
|
export function isFullScreenPage(pathname: string): boolean {
|
||||||
|
return FULL_SCREEN_PAGE.test(pathname);
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { Suspense } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
||||||
import {
|
import {
|
||||||
OFFLINE_PACKS_QUERY_KEY,
|
OFFLINE_PACKS_QUERY_KEY,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default function OfflinePage() {
|
|||||||
<Link
|
<Link
|
||||||
key={stored.apiaryId}
|
key={stored.apiaryId}
|
||||||
href={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`}
|
href={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`}
|
||||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 flex-1 flex-col">
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="font-medium">{stored.pack.apiary.name}</span>
|
<span className="font-medium">{stored.pack.apiary.name}</span>
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new page comes in (spec 17.4): at "Voll" it fades in and rises 6 px (--motion-enter,
|
||||||
|
* --ease-out-expo), at "Dezent" or with the system's reduce motion it only fades (globals.css),
|
||||||
|
* at "Aus" it stands at once. There is no exit: the old page goes at once, navigation never waits.
|
||||||
|
*/
|
||||||
export function PageTransition({ children }: { children: ReactNode }) {
|
export function PageTransition({ children }: { children: ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
<div key={pathname} className="motion-safe:animate-page-in">
|
<div key={pathname} className="animate-page-in">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { FormEvent, useRef, useState } from "react";
|
import { FormEvent, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createProductionBatch } from "@/lib/production-batches/api";
|
import { createProductionBatch } from "@/lib/production-batches/api";
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchProduct } from "@/lib/products/api";
|
import { fetchProduct } from "@/lib/products/api";
|
||||||
import { fetchProductionBatches } from "@/lib/production-batches/api";
|
import { fetchProductionBatches } from "@/lib/production-batches/api";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { createProduct } from "@/lib/products/api";
|
import { createProduct } from "@/lib/products/api";
|
||||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||||
import { errorMessage } from "@/lib/forms/error-message";
|
import { errorMessage } from "@/lib/forms/error-message";
|
||||||
|
|||||||
@@ -27,32 +27,14 @@ export default function ProductsPage() {
|
|||||||
const products = productsQuery.data ?? [];
|
const products = productsQuery.data ?? [];
|
||||||
const activeProducts = products.filter((product) => product.status === "ACTIVE");
|
const activeProducts = products.filter((product) => product.status === "ACTIVE");
|
||||||
const historyProducts = products.filter((product) => product.status !== "ACTIVE");
|
const historyProducts = products.filter((product) => product.status !== "ACTIVE");
|
||||||
const visibleProducts = tab === "active" ? activeProducts : historyProducts;
|
|
||||||
|
|
||||||
|
const listOf = (bucket: EntityLifecycleTab) => {
|
||||||
|
const visibleProducts = bucket === "active" ? activeProducts : historyProducts;
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
|
||||||
<PageHeader
|
|
||||||
area="products"
|
|
||||||
title="Produkte"
|
|
||||||
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
|
|
||||||
actions={
|
|
||||||
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
|
||||||
+ Neues Produkt
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActiveHistoryTabs
|
|
||||||
value={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
activeCount={activeProducts.length}
|
|
||||||
historyCount={historyProducts.length}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<QueryState query={productsQuery} what="Produkte">
|
<QueryState query={productsQuery} what="Produkte">
|
||||||
{() =>
|
{() =>
|
||||||
visibleProducts.length === 0 ? (
|
visibleProducts.length === 0 ? (
|
||||||
tab === "active" ? (
|
bucket === "active" ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ShoppingBag}
|
icon={ShoppingBag}
|
||||||
title="Noch keine Produkte"
|
title="Noch keine Produkte"
|
||||||
@@ -75,6 +57,30 @@ export default function ProductsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||||
|
<PageHeader
|
||||||
|
area="products"
|
||||||
|
title="Produkte"
|
||||||
|
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
|
||||||
|
actions={
|
||||||
|
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
||||||
|
+ Neues Produkt
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActiveHistoryTabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={setTab}
|
||||||
|
activeCount={activeProducts.length}
|
||||||
|
historyCount={historyProducts.length}
|
||||||
|
>
|
||||||
|
{{ active: listOf("active"), history: listOf("history") }}
|
||||||
|
</ActiveHistoryTabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers";
|
import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers";
|
||||||
import { AppearanceSection } from "./appearance-section";
|
import { AppearanceSection } from "./appearance-section";
|
||||||
@@ -30,21 +30,20 @@ describe("AppearanceSection", () => {
|
|||||||
expect(screen.getByText("Gilt nur für dieses Gerät.")).toBeInTheDocument();
|
expect(screen.getByText("Gilt nur für dieses Gerät.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides Dunkel and Wie Gerät without the dev flag", () => {
|
it("starts on Hell and offers Dunkel and Wie Gerät without a flag", () => {
|
||||||
render(<AppearanceSection />);
|
render(<AppearanceSection />);
|
||||||
expect(screen.getByRole("radio", { name: /^Hell/ })).toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: /^Hell/ })).toBeChecked();
|
||||||
expect(screen.queryByRole("radio", { name: /^Dunkel/ })).not.toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: /^Dunkel/ })).not.toBeChecked();
|
||||||
expect(screen.queryByRole("radio", { name: /^Wie Gerät/ })).not.toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: /^Wie Gerät/ })).not.toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers Dunkel and Wie Gerät with the dev flag", async () => {
|
it("dark is selectable without a flag", async () => {
|
||||||
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
|
|
||||||
render(<AppearanceSection />);
|
render(<AppearanceSection />);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("radio", { name: /^Dunkel/ }));
|
await userEvent.click(screen.getByRole("radio", { name: /^Dunkel/ }));
|
||||||
|
|
||||||
expect(document.documentElement).toHaveClass("dark");
|
expect(document.documentElement).toHaveClass("dark");
|
||||||
expect(screen.getByRole("radio", { name: /^Wie Gerät/ })).toBeInTheDocument();
|
expect(window.localStorage.getItem("apicore.theme")).toBe("dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("changes the motion level to Dezent and applies it", async () => {
|
it("changes the motion level to Dezent and applies it", async () => {
|
||||||
@@ -55,4 +54,18 @@ describe("AppearanceSection", () => {
|
|||||||
expect(document.documentElement.dataset.motion).toBe("subtle");
|
expect(document.documentElement.dataset.motion).toBe("subtle");
|
||||||
expect(window.localStorage.getItem("apicore.motion")).toBe("subtle");
|
expect(window.localStorage.getItem("apicore.motion")).toBe("subtle");
|
||||||
});
|
});
|
||||||
|
it("offers colour scheme, contrast and motion as cards with a description each", () => {
|
||||||
|
render(<AppearanceSection />);
|
||||||
|
|
||||||
|
for (const group of ["Farbschema", "Kontrast", "Bewegung & Effekte"]) {
|
||||||
|
for (const radio of within(screen.getByRole("radiogroup", { name: group })).getAllByRole("radio")) {
|
||||||
|
expect(radio).toHaveClass("min-h-14");
|
||||||
|
expect(radio).toHaveAccessibleDescription(/\S/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(screen.getByRole("radio", { name: "Hell" })).toHaveAccessibleDescription("Am besten bei Tageslicht.");
|
||||||
|
expect(screen.getByRole("radio", { name: "Dezent" })).toHaveAccessibleDescription(
|
||||||
|
"Nur kurzes Ein- und Ausblenden, nichts bewegt sich über den Bildschirm.",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -3,25 +3,18 @@
|
|||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import { useAppearance, type ContrastChoice, type ThemeChoice } from "@/lib/appearance/appearance";
|
||||||
isDarkEnabled,
|
|
||||||
useAppearance,
|
|
||||||
type ContrastChoice,
|
|
||||||
type ThemeChoice,
|
|
||||||
} from "@/lib/appearance/appearance";
|
|
||||||
import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference";
|
import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference";
|
||||||
|
|
||||||
const OPTION_CLASS = "h-auto justify-start px-3 py-2 text-left";
|
|
||||||
|
|
||||||
const THEME_OPTIONS: readonly ChoiceOption<ThemeChoice>[] = [
|
const THEME_OPTIONS: readonly ChoiceOption<ThemeChoice>[] = [
|
||||||
{ value: "light", label: "Hell – am besten bei Tageslicht" },
|
{ value: "light", label: "Hell", description: "Am besten bei Tageslicht." },
|
||||||
{ value: "dark", label: "Dunkel – für abends" },
|
{ value: "dark", label: "Dunkel", description: "Für abends." },
|
||||||
{ value: "system", label: "Wie Gerät" },
|
{ value: "system", label: "Wie Gerät", description: "Folgt der Einstellung des Geräts." },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONTRAST_OPTIONS: readonly ChoiceOption<ContrastChoice>[] = [
|
const CONTRAST_OPTIONS: readonly ChoiceOption<ContrastChoice>[] = [
|
||||||
{ value: "normal", label: "Normal" },
|
{ value: "normal", label: "Normal", description: "Für drinnen und Schatten." },
|
||||||
{ value: "sun", label: "Sonne – maximaler Kontrast, größere Schrift, für draußen" },
|
{ value: "sun", label: "Sonne", description: "Maximaler Kontrast, größere Schrift, für draußen." },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOTION_LABELS: Record<MotionLevel, string> = { full: "Voll", subtle: "Dezent", off: "Aus" };
|
const MOTION_LABELS: Record<MotionLevel, string> = { full: "Voll", subtle: "Dezent", off: "Aus" };
|
||||||
@@ -33,13 +26,12 @@ const MOTION_DESCRIPTIONS: Record<MotionLevel, string> = {
|
|||||||
const MOTION_OPTIONS: readonly ChoiceOption<MotionLevel>[] = MOTION_LEVELS.map((value) => ({
|
const MOTION_OPTIONS: readonly ChoiceOption<MotionLevel>[] = MOTION_LEVELS.map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: MOTION_LABELS[value],
|
label: MOTION_LABELS[value],
|
||||||
|
description: MOTION_DESCRIPTIONS[value],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function AppearanceSection() {
|
export function AppearanceSection() {
|
||||||
const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance();
|
const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance();
|
||||||
const ids = { theme: useId(), contrast: useId(), motion: useId(), motionHint: useId() };
|
const ids = { theme: useId(), contrast: useId(), motion: useId() };
|
||||||
// Dark has unreadable old values until task A4; the dev flag shows it for work on A4.
|
|
||||||
const themeOptions = isDarkEnabled() ? THEME_OPTIONS : THEME_OPTIONS.slice(0, 1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -56,11 +48,11 @@ export function AppearanceSection() {
|
|||||||
Farbschema
|
Farbschema
|
||||||
</p>
|
</p>
|
||||||
<ChoiceGroup
|
<ChoiceGroup
|
||||||
options={themeOptions}
|
options={THEME_OPTIONS}
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={setTheme}
|
onChange={setTheme}
|
||||||
labelledBy={ids.theme}
|
labelledBy={ids.theme}
|
||||||
optionClassName={OPTION_CLASS}
|
appearance="cards"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -72,7 +64,7 @@ export function AppearanceSection() {
|
|||||||
value={contrast}
|
value={contrast}
|
||||||
onChange={setContrast}
|
onChange={setContrast}
|
||||||
labelledBy={ids.contrast}
|
labelledBy={ids.contrast}
|
||||||
optionClassName={OPTION_CLASS}
|
appearance="cards"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -84,13 +76,8 @@ export function AppearanceSection() {
|
|||||||
value={motion}
|
value={motion}
|
||||||
onChange={setMotion}
|
onChange={setMotion}
|
||||||
labelledBy={ids.motion}
|
labelledBy={ids.motion}
|
||||||
describedBy={ids.motionHint}
|
appearance="cards"
|
||||||
className="grid-cols-3"
|
|
||||||
optionClassName="px-3 py-2"
|
|
||||||
/>
|
/>
|
||||||
<p id={ids.motionHint} className="text-sm text-muted-foreground">
|
|
||||||
{MOTION_DESCRIPTIONS[motion]}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Gilt nur für dieses Gerät.</p>
|
<p className="text-sm text-muted-foreground">Gilt nur für dieses Gerät.</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
validateQuietHours,
|
validateQuietHours,
|
||||||
} from "@/lib/notification-settings/validation";
|
} from "@/lib/notification-settings/validation";
|
||||||
import { errorMessage } from "@/lib/forms/error-message";
|
import { errorMessage } from "@/lib/forms/error-message";
|
||||||
import { SwitchRow } from "@/components/form/switch-row";
|
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -104,17 +104,19 @@ function NotificationSettingsForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||||
<SwitchRow
|
<CheckboxField
|
||||||
|
role="switch"
|
||||||
label="Erinnerung je Aufgabe"
|
label="Erinnerung je Aufgabe"
|
||||||
description="Eine Meldung, wenn eine Aufgabe fällig wird."
|
description="Eine Meldung, wenn eine Aufgabe fällig wird."
|
||||||
checked={draft.taskDueEnabled}
|
checked={draft.taskDueEnabled}
|
||||||
onChange={(checked) => change("taskDueEnabled", checked)}
|
onCheckedChange={(checked) => change("taskDueEnabled", checked)}
|
||||||
/>
|
/>
|
||||||
<SwitchRow
|
<CheckboxField
|
||||||
|
role="switch"
|
||||||
label="Morgenübersicht"
|
label="Morgenübersicht"
|
||||||
description="Eine Meldung am Morgen mit allem, was heute ansteht."
|
description="Eine Meldung am Morgen mit allem, was heute ansteht."
|
||||||
checked={draft.digestEnabled}
|
checked={draft.digestEnabled}
|
||||||
onChange={(checked) => change("digestEnabled", checked)}
|
onCheckedChange={(checked) => change("digestEnabled", checked)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="digest-time">Uhrzeit der Morgenübersicht</Label>
|
<Label htmlFor="digest-time">Uhrzeit der Morgenübersicht</Label>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChangeEvent, FormEvent, useRef, useState } from "react";
|
import { ChangeEvent, FormEvent, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ import { formatBerlinDate, formatBerlinTime } from "@/lib/telegram/format";
|
|||||||
import type { StepUpProof, TelegramLinkToken } from "@/lib/telegram/types";
|
import type { StepUpProof, TelegramLinkToken } from "@/lib/telegram/types";
|
||||||
import { StepUpDialog } from "@/components/auth/step-up-dialog";
|
import { StepUpDialog } from "@/components/auth/step-up-dialog";
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
import { SwitchRow } from "@/components/form/switch-row";
|
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
|
||||||
import { ErrorState } from "@/components/query-state";
|
import { ErrorState } from "@/components/query-state";
|
||||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
@@ -100,23 +99,26 @@ function DeliverySettings({ settings, isOffline, hintId }: DeliverySettingsProps
|
|||||||
<legend className="text-sm font-medium">Das kommt per Telegram</legend>
|
<legend className="text-sm font-medium">Das kommt per Telegram</legend>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{NOTIFICATION_CATEGORIES.map((category) => (
|
{NOTIFICATION_CATEGORIES.map((category) => (
|
||||||
<ToggleChip
|
<CheckboxField
|
||||||
|
appearance="tile"
|
||||||
key={category}
|
key={category}
|
||||||
label={NOTIFICATION_CATEGORY_LABELS[category]}
|
label={NOTIFICATION_CATEGORY_LABELS[category]}
|
||||||
checked={settings.telegramCategories.includes(category)}
|
checked={settings.telegramCategories.includes(category)}
|
||||||
onChange={(checked) => toggleCategory(category, checked)}
|
onCheckedChange={(checked) => toggleCategory(category, checked)}
|
||||||
describedBy={isOffline ? hintId : undefined}
|
describedBy={isOffline ? hintId : undefined}
|
||||||
disabled={mutation.isPending || isOffline}
|
disabled={mutation.isPending || isOffline}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<SwitchRow
|
<CheckboxField
|
||||||
|
role="switch"
|
||||||
label="Mit Details"
|
label="Mit Details"
|
||||||
description={PRIVACY_NOTE}
|
description={PRIVACY_NOTE}
|
||||||
checked={settings.telegramDetail}
|
checked={settings.telegramDetail}
|
||||||
disabled={mutation.isPending || isOffline}
|
disabled={mutation.isPending || isOffline}
|
||||||
onChange={(checked) => mutation.mutate({ telegramDetail: checked })}
|
describedBy={isOffline ? hintId : undefined}
|
||||||
|
onCheckedChange={(checked) => mutation.mutate({ telegramDetail: checked })}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p role="alert" className="text-sm font-medium text-destructive">
|
<p role="alert" className="text-sm font-medium text-destructive">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { fetchQueen } from "@/lib/queens/api";
|
import { fetchQueen } from "@/lib/queens/api";
|
||||||
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
|
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
|
|||||||