Phase 5 cleanup

This commit is contained in:
Aerilyn Weber 2026-04-26 18:44:59 +09:00
parent 5536acd67d
commit 76a516a417
136 changed files with 6322 additions and 1985 deletions

View file

@ -235,9 +235,7 @@ describe(CabinetEventsRepository.name, () => {
];
const byPeriod = [{ _id: '2024-01', totalSpent: 100 }];
mockAggregate
.mockResolvedValueOnce(byMedicine)
.mockResolvedValueOnce(byPeriod);
mockAggregate.mockResolvedValueOnce(byMedicine).mockResolvedValueOnce(byPeriod);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
@ -256,9 +254,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('returns null currency when no medicine data', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });
@ -269,9 +265,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('filters by medicineId', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'month', medicineId: 'med-1' });
@ -279,9 +273,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('filters by startDate only', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
@ -292,9 +284,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('filters by endDate only', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
@ -305,9 +295,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('filters by both startDate and endDate', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', {
period: 'month',
@ -319,9 +307,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('uses quarter date format', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'quarter' });
@ -329,9 +315,7 @@ describe(CabinetEventsRepository.name, () => {
});
it('uses year date format', async () => {
mockAggregate
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await repo.getSpendingSummary('hh1', { period: 'year' });
@ -350,9 +334,7 @@ describe(CabinetEventsRepository.name, () => {
currency: null,
},
];
mockAggregate
.mockResolvedValueOnce(byMedicine)
.mockResolvedValueOnce([]);
mockAggregate.mockResolvedValueOnce(byMedicine).mockResolvedValueOnce([]);
const result = await repo.getSpendingSummary('hh1', { period: 'month' });

View file

@ -130,7 +130,11 @@ export class CabinetEventsRepository {
{
$addFields: {
avgUnitPrice: {
$cond: [{ $gt: ['$totalQuantity', 0] }, { $divide: ['$totalSpent', '$totalQuantity'] }, 0],
$cond: [
{ $gt: ['$totalQuantity', 0] },
{ $divide: ['$totalSpent', '$totalQuantity'] },
0,
],
},
},
},
@ -153,7 +157,7 @@ export class CabinetEventsRepository {
0,
);
const currency =
byMedicine.length > 0 ? (byMedicine[0].currency as string | null) ?? null : null;
byMedicine.length > 0 ? ((byMedicine[0].currency as string | null) ?? null) : null;
return {
totalSpent,
@ -174,7 +178,8 @@ export class CabinetEventsRepository {
}
public async getAvgUnitPriceByMedicine(householdId: string, medicineIds: string[]) {
if (medicineIds.length === 0) return new Map<string, { avgUnitPrice: number; currency: string | null }>();
if (medicineIds.length === 0)
return new Map<string, { avgUnitPrice: number; currency: string | null }>();
const results = await CabinetEventModel.aggregate([
{
@ -196,7 +201,11 @@ export class CabinetEventsRepository {
{
$addFields: {
avgUnitPrice: {
$cond: [{ $gt: ['$totalQuantity', 0] }, { $divide: ['$totalSpent', '$totalQuantity'] }, 0],
$cond: [
{ $gt: ['$totalQuantity', 0] },
{ $divide: ['$totalSpent', '$totalQuantity'] },
0,
],
},
},
},

View file

@ -234,11 +234,14 @@ describe('cabinet-events.routes', () => {
});
expect(res.statusCode).toBe(200);
expect(mockListEvents).toHaveBeenCalledWith('hh1', expect.objectContaining({
medicineId: 'med-1',
eventType: 'purchased',
limit: 10,
}));
expect(mockListEvents).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({
medicineId: 'med-1',
eventType: 'purchased',
limit: 10,
}),
);
});
});
@ -276,10 +279,14 @@ describe('cabinet-events.routes', () => {
});
expect(res.statusCode).toBe(200);
expect(mockGetEventsByItem).toHaveBeenCalledWith('hh1', 'ci-1', expect.objectContaining({
limit: 5,
cursor: 'abc',
}));
expect(mockGetEventsByItem).toHaveBeenCalledWith(
'hh1',
'ci-1',
expect.objectContaining({
limit: 5,
cursor: 'abc',
}),
);
});
it('handles ObjectId and Date objects in by-item response', async () => {
@ -356,10 +363,13 @@ describe('cabinet-events.routes', () => {
});
expect(res.statusCode).toBe(200);
expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.objectContaining({
period: 'quarter',
medicineId: 'med-1',
}));
expect(mockGetSpendingSummary).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({
period: 'quarter',
medicineId: 'med-1',
}),
);
});
it('returns empty summary with null currency', async () => {

View file

@ -142,10 +142,7 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetEventsService');
const summary = await service.getSpendingSummary(
request.params.householdId,
request.query,
);
const summary = await service.getSpendingSummary(request.params.householdId, request.query);
return reply.send(summary);
},
});

View file

@ -136,7 +136,9 @@ describe(CabinetEventsService.name, () => {
const result = await service.getAvgUnitPrices('hh1', ['med-1']);
expect(result).toEqual(expected);
expect(mockCabinetEventsRepo.getAvgUnitPriceByMedicine).toHaveBeenCalledWith('hh1', ['med-1']);
expect(mockCabinetEventsRepo.getAvgUnitPriceByMedicine).toHaveBeenCalledWith('hh1', [
'med-1',
]);
});
});
});

View file

@ -1,4 +1,7 @@
import type { CabinetEventsRepository, CreateCabinetEventData } from './cabinet-events.repository.js';
import type {
CabinetEventsRepository,
CreateCabinetEventData,
} from './cabinet-events.repository.js';
import type { CabinetEventQueryInput, SpendingSummaryQueryInput } from '@meshitrack/shared';
interface Deps {

View file

@ -1,5 +1,9 @@
import { CabinetItemModel } from '../../schemas/cabinet-item.schema.js';
import type { CabinetItemStatus, CreateCabinetItemInput, UpdateCabinetItemInput } from '@meshitrack/shared';
import type {
CabinetItemStatus,
CreateCabinetItemInput,
UpdateCabinetItemInput,
} from '@meshitrack/shared';
interface FindByHouseholdQuery {
medicineId?: string;

View file

@ -249,7 +249,11 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('cabinetService');
await service.delete(request.params.id, request.params.householdId, request.user.keycloakId);
await service.delete(
request.params.id,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(204).send();
},
});

View file

@ -239,7 +239,12 @@ describe(CabinetService.name, () => {
describe('update', () => {
it('updates and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
const updated = { _id: 'ci-1', quantity: 25 };
mockCabinetRepo.update.mockResolvedValue(updated);
@ -249,7 +254,12 @@ describe(CabinetService.name, () => {
});
it('logs ADJUSTED event when quantity changes', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 25 });
await service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1');
@ -265,7 +275,12 @@ describe(CabinetService.name, () => {
});
it('does not log event when quantity unchanged', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue({ _id: 'ci-1', quantity: 30 });
await service.update('ci-1', 'hh1', { notes: 'updated' }, 'user-1');
@ -282,7 +297,12 @@ describe(CabinetService.name, () => {
});
it('throws NotFoundError when update returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.update.mockResolvedValue(null);
await expect(service.update('ci-1', 'hh1', { quantity: 25 }, 'user-1')).rejects.toThrow(
@ -293,7 +313,12 @@ describe(CabinetService.name, () => {
describe('adjustQuantity', () => {
it('adjusts quantity and returns item', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
const updated = { _id: 'ci-1', quantity: 27 };
mockCabinetRepo.adjustQuantity.mockResolvedValue(updated);
@ -303,7 +328,12 @@ describe(CabinetService.name, () => {
});
it('logs ADJUSTED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.adjustQuantity.mockResolvedValue({ _id: 'ci-1', quantity: 27 });
await service.adjustQuantity('ci-1', 'hh1', -3, 'user-1', 'took some');
@ -334,7 +364,12 @@ describe(CabinetService.name, () => {
});
it('throws NotFoundError when adjust returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 30, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 30,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.adjustQuantity.mockResolvedValue(null);
await expect(service.adjustQuantity('ci-1', 'hh1', 5, 'user-1')).rejects.toThrow(
@ -357,7 +392,12 @@ describe(CabinetService.name, () => {
describe('delete', () => {
it('soft deletes item and logs DELETED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 10, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 10,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.softDelete.mockResolvedValue({ _id: 'ci-1', isDeleted: true });
const result = await service.delete('ci-1', 'hh1', 'user-1');
@ -376,20 +416,34 @@ describe(CabinetService.name, () => {
it('throws NotFoundError when item does not exist', async () => {
mockCabinetRepo.findById.mockResolvedValue(null);
await expect(service.delete('ci-missing', 'hh1', 'user-1')).rejects.toThrow('Cabinet item not found');
await expect(service.delete('ci-missing', 'hh1', 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
it('throws NotFoundError when softDelete returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 5, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 5,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('ci-1', 'hh1', 'user-1')).rejects.toThrow('Cabinet item not found');
await expect(service.delete('ci-1', 'hh1', 'user-1')).rejects.toThrow(
'Cabinet item not found',
);
});
});
describe('discard', () => {
it('discards item and logs DISCARDED event', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 20, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 20,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.discard.mockResolvedValue({ _id: 'ci-1', quantity: 0, isDeleted: true });
const result = await service.discard('ci-1', 'hh1', 'user-1', 'expired', 'smelled off');
@ -409,7 +463,12 @@ describe(CabinetService.name, () => {
});
it('throws BadRequestError when quantity is zero', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 0, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 0,
medicineId: 'med-1',
medicineName: 'Test',
});
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(
'Cannot discard an item with zero quantity',
@ -425,7 +484,12 @@ describe(CabinetService.name, () => {
});
it('throws NotFoundError when discard returns null', async () => {
mockCabinetRepo.findById.mockResolvedValue({ _id: 'ci-1', quantity: 10, medicineId: 'med-1', medicineName: 'Test' });
mockCabinetRepo.findById.mockResolvedValue({
_id: 'ci-1',
quantity: 10,
medicineId: 'med-1',
medicineName: 'Test',
});
mockCabinetRepo.discard.mockResolvedValue(null);
await expect(service.discard('ci-1', 'hh1', 'user-1', 'expired')).rejects.toThrow(

View file

@ -2,10 +2,7 @@ import type { CabinetRepository } from './cabinet.repository.js';
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
import {
CabinetEventType,
CabinetEventSourceType,
} from '@meshitrack/shared';
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
import type {
CreateCabinetItemInput,
UpdateCabinetItemInput,
@ -123,7 +120,12 @@ export class CabinetService {
return item;
}
public async update(id: string, householdId: string, data: UpdateCabinetItemInput, userId: string) {
public async update(
id: string,
householdId: string,
data: UpdateCabinetItemInput,
userId: string,
) {
const existing = await this.getById(id, householdId);
const updated = await this.cabinetRepository.update(id, householdId, data);
if (!updated) throw new NotFoundError('Cabinet item not found');
@ -203,7 +205,13 @@ export class CabinetService {
return deleted;
}
public async discard(id: string, householdId: string, userId: string, reason: string, notes?: string) {
public async discard(
id: string,
householdId: string,
userId: string,
reason: string,
notes?: string,
) {
const existing = await this.getById(id, householdId);
if (existing.quantity === 0) {
throw new BadRequestError('Cannot discard an item with zero quantity');

View file

@ -23,9 +23,13 @@ vi.mock('../../schemas/medicine-price.schema.js', () => {
class FakeModel {
data: unknown;
constructor(data: unknown) { this.data = data; }
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() { return this.data; }
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static aggregate = vi.fn(() => aggregateChain());
@ -231,8 +235,17 @@ describe(MedicinePricesRepository.name, () => {
it('handles non-empty analytics results', async () => {
mockAggregate
.mockResolvedValueOnce([{ period: '2026-01', total: 50 }])
.mockResolvedValueOnce([{ medicineId: 'med-1', medicineName: 'Acetaminophen', totalSpent: 50, avgPricePerUnit: 0.1 }])
.mockResolvedValueOnce([{ storeId: 'st-1', storeName: 'Walgreens', totalSpent: 50, purchaseCount: 5 }])
.mockResolvedValueOnce([
{
medicineId: 'med-1',
medicineName: 'Acetaminophen',
totalSpent: 50,
avgPricePerUnit: 0.1,
},
])
.mockResolvedValueOnce([
{ storeId: 'st-1', storeName: 'Walgreens', totalSpent: 50, purchaseCount: 5 },
])
.mockResolvedValueOnce([]);
const result = await repo.getAnalytics('hh1', { period: 'month' });

View file

@ -1,5 +1,8 @@
import { MedicinePriceModel } from '../../schemas/medicine-price.schema.js';
import type { MedicinePriceHistoryQueryInput, MedicinePriceAnalyticsQueryInput } from '@meshitrack/shared';
import type {
MedicinePriceHistoryQueryInput,
MedicinePriceAnalyticsQueryInput,
} from '@meshitrack/shared';
export interface CreateMedicinePriceData {
householdId: string;
@ -93,11 +96,7 @@ export class MedicinePricesRepository {
}));
}
public async getLatestForMedicine(
householdId: string,
medicineId: string,
storeId?: string,
) {
public async getLatestForMedicine(householdId: string, medicineId: string, storeId?: string) {
const filter: Record<string, unknown> = { householdId, medicineId };
if (storeId) filter['storeId'] = storeId;
return MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec();
@ -132,7 +131,15 @@ export class MedicinePricesRepository {
},
{ $sort: { totalSpent: -1 } },
{ $limit: 10 },
{ $project: { _id: 0, medicineId: '$_id', medicineName: 1, totalSpent: 1, avgPricePerUnit: 1 } },
{
$project: {
_id: 0,
medicineId: '$_id',
medicineName: 1,
totalSpent: 1,
avgPricePerUnit: 1,
},
},
]).exec(),
MedicinePriceModel.aggregate([
@ -195,9 +202,26 @@ export class MedicinePricesRepository {
return {
spendingOverTime: spendingOverTime as { period: string; total: number }[],
topBySpending: topBySpending as { medicineId: string; medicineName: string; totalSpent: number; avgPricePerUnit: number }[],
spendingByStore: spendingByStore as { storeId: string; storeName: string; totalSpent: number; purchaseCount: number }[],
priceAlerts: priceAlerts as { medicineId: string; medicineName: string; storeName: string; previousPrice: number; currentPrice: number; changePercent: number }[],
topBySpending: topBySpending as {
medicineId: string;
medicineName: string;
totalSpent: number;
avgPricePerUnit: number;
}[],
spendingByStore: spendingByStore as {
storeId: string;
storeName: string;
totalSpent: number;
purchaseCount: number;
}[],
priceAlerts: priceAlerts as {
medicineId: string;
medicineName: string;
storeName: string;
previousPrice: number;
currentPrice: number;
changePercent: number;
}[],
};
}
}

View file

@ -18,17 +18,14 @@ vi.mock('jose', () => ({
}),
}));
const {
mockRecordPrice,
mockGetPriceHistory,
mockCompareStores,
mockGetAnalytics,
} = vi.hoisted(() => ({
mockRecordPrice: vi.fn(),
mockGetPriceHistory: vi.fn(),
mockCompareStores: vi.fn(),
mockGetAnalytics: vi.fn(),
}));
const { mockRecordPrice, mockGetPriceHistory, mockCompareStores, mockGetAnalytics } = vi.hoisted(
() => ({
mockRecordPrice: vi.fn(),
mockGetPriceHistory: vi.fn(),
mockCompareStores: vi.fn(),
mockGetAnalytics: vi.fn(),
}),
);
vi.mock('./medicine-prices.repository.js', () => ({
MedicinePricesRepository: class {
@ -198,11 +195,13 @@ describe('medicine-prices.routes', () => {
});
it('handles Date objects in response', async () => {
mockRecordPrice.mockResolvedValue(makeFakePriceRecord({
_id: { toString: () => 'pr-obj' },
date: new Date('2026-01-15T00:00:00.000Z'),
createdAt: new Date('2026-01-15T00:00:00.000Z'),
}));
mockRecordPrice.mockResolvedValue(
makeFakePriceRecord({
_id: { toString: () => 'pr-obj' },
date: new Date('2026-01-15T00:00:00.000Z'),
createdAt: new Date('2026-01-15T00:00:00.000Z'),
}),
);
const res = await app.inject({
method: 'POST',
@ -238,7 +237,10 @@ describe('medicine-prices.routes', () => {
});
it('passes query params to service', async () => {
mockGetPriceHistory.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
mockGetPriceHistory.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
await app.inject({
method: 'GET',

View file

@ -13,8 +13,6 @@ import {
} from '@meshitrack/shared';
import { MedicinePricesRepository } from './medicine-prices.repository.js';
import { MedicinePricesService } from './medicine-prices.service.js';
import { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
import { StoresRepository } from '../stores/stores.repository.js';
type AnyPriceDoc = {
_id: string | { toString: () => string };

View file

@ -40,7 +40,10 @@ describe(MedicinePricesService.name, () => {
};
it('creates price record with computed pricePerUnit', async () => {
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
mockProductsRepo.findById.mockResolvedValue({
medicineName: 'Acetaminophen',
brand: 'Tylenol',
});
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
const record = { _id: 'pr-1', pricePerUnit: 0.1 };
mockPricesRepo.create.mockResolvedValue(record);
@ -49,7 +52,11 @@ describe(MedicinePricesService.name, () => {
expect(result).toEqual(record);
expect(mockPricesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ pricePerUnit: 0.1, medicineName: 'Acetaminophen', storeName: 'Walgreens' }),
expect.objectContaining({
pricePerUnit: 0.1,
medicineName: 'Acetaminophen',
storeName: 'Walgreens',
}),
);
});
@ -66,11 +73,18 @@ describe(MedicinePricesService.name, () => {
});
it('uses provided date when given', async () => {
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
mockProductsRepo.findById.mockResolvedValue({
medicineName: 'Acetaminophen',
brand: 'Tylenol',
});
mockStoresRepo.findById.mockResolvedValue({ name: 'Walgreens' });
mockPricesRepo.create.mockResolvedValue({ _id: 'pr-1' });
await service.recordPrice({ ...validInput, date: '2026-01-15T00:00:00.000Z' }, 'hh1', 'user-1');
await service.recordPrice(
{ ...validInput, date: '2026-01-15T00:00:00.000Z' },
'hh1',
'user-1',
);
expect(mockPricesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ date: new Date('2026-01-15T00:00:00.000Z') }),
@ -86,7 +100,10 @@ describe(MedicinePricesService.name, () => {
});
it('throws NotFoundError when store not found', async () => {
mockProductsRepo.findById.mockResolvedValue({ medicineName: 'Acetaminophen', brand: 'Tylenol' });
mockProductsRepo.findById.mockResolvedValue({
medicineName: 'Acetaminophen',
brand: 'Tylenol',
});
mockStoresRepo.findById.mockResolvedValue(null);
await expect(service.recordPrice(validInput, 'hh1', 'user-1')).rejects.toThrow(

View file

@ -19,7 +19,11 @@ export class MedicinePricesService {
private readonly medicineProductsRepository: MedicineProductsRepository;
private readonly storesRepository: StoresRepository;
public constructor({ medicinePricesRepository, medicineProductsRepository, storesRepository }: Deps) {
public constructor({
medicinePricesRepository,
medicineProductsRepository,
storesRepository,
}: Deps) {
this.medicinePricesRepository = medicinePricesRepository;
this.medicineProductsRepository = medicineProductsRepository;
this.storesRepository = storesRepository;

View file

@ -179,7 +179,9 @@ describe('medicine-products.routes', () => {
});
it('includes concentration fields in response when present', async () => {
mockFindById.mockResolvedValue(makeFakeProduct({ concentration: 5, concentrationUnit: 'mg/ml' }));
mockFindById.mockResolvedValue(
makeFakeProduct({ concentration: 5, concentrationUnit: 'mg/ml' }),
);
const res = await app.inject({
method: 'GET',

View file

@ -74,7 +74,10 @@ describe(OrganizerRepository.name, () => {
});
it('sets hasMore when more items exist', async () => {
const items = Array.from({ length: 3 }, (_, i) => ({ _id: `fill-${i}`, regimenName: `R${i}` }));
const items = Array.from({ length: 3 }, (_, i) => ({
_id: `fill-${i}`,
regimenName: `R${i}`,
}));
mockFind.mockResolvedValue(items);
const result = await repo.findByHousehold('hh1', 'user-1', { limit: 2 });

View file

@ -76,9 +76,7 @@ function makeFakeFill(overrides = {}) {
quantityTaken: 7,
wasShort: false,
shortage: 0,
deductions: [
{ cabinetItemId: 'ci-1', quantityTaken: 7 },
],
deductions: [{ cabinetItemId: 'ci-1', quantityTaken: 7 }],
},
],
status: OrganizerFillStatus.COMPLETED,
@ -182,11 +180,15 @@ describe('organizer.routes', () => {
});
expect(res.statusCode).toBe(200);
expect(mockListFills).toHaveBeenCalledWith('hh1', 'kc-1', expect.objectContaining({
regimenId: 'reg-1',
status: OrganizerFillStatus.COMPLETED,
limit: 10,
}));
expect(mockListFills).toHaveBeenCalledWith(
'hh1',
'kc-1',
expect.objectContaining({
regimenId: 'reg-1',
status: OrganizerFillStatus.COMPLETED,
limit: 10,
}),
);
});
it('handles ObjectId and Date serialization in fill response', async () => {
@ -439,7 +441,12 @@ describe('organizer.routes', () => {
expect(mockFill).toHaveBeenCalledWith(
'hh1',
'kc-1',
expect.objectContaining({ regimenId: 'reg-1', numberOfDays: 7, allowPartial: false, notes: 'test note' }),
expect.objectContaining({
regimenId: 'reg-1',
numberOfDays: 7,
allowPartial: false,
notes: 'test note',
}),
);
});
});

View file

@ -82,7 +82,9 @@ describe(OrganizerService.name, () => {
const response = await service.listFills('hh1', 'user-1', { limit: 20 });
expect(response).toEqual(result);
expect(mockOrganizerRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', { limit: 20 });
expect(mockOrganizerRepo.findByHousehold).toHaveBeenCalledWith('hh1', 'user-1', {
limit: 20,
});
});
});

View file

@ -66,7 +66,12 @@ export class OrganizerService {
return fill;
}
public async preview(householdId: string, userId: string, regimenId: string, numberOfDays: number) {
public async preview(
householdId: string,
userId: string,
regimenId: string,
numberOfDays: number,
) {
const regimen = await this.regimensService.getById(regimenId, householdId, userId);
if (!regimen.isActive) {
throw new BadRequestError('Regimen is not active');
@ -132,7 +137,12 @@ export class OrganizerService {
}
public async fill(householdId: string, userId: string, input: OrganizerFillInput) {
const previewResult = await this.preview(householdId, userId, input.regimenId, input.numberOfDays);
const previewResult = await this.preview(
householdId,
userId,
input.regimenId,
input.numberOfDays,
);
if (!input.allowPartial && previewResult.hasShortages) {
throw new BadRequestError(
@ -243,7 +253,10 @@ export class OrganizerService {
for (const item of fill.items) {
for (const deduction of item.deductions) {
// Get current quantity before restoring
const current = await this.cabinetRepository.findById(deduction.cabinetItemId, householdId);
const current = await this.cabinetRepository.findById(
deduction.cabinetItemId,
householdId,
);
const quantityBefore = current?.quantity ?? 0;
await this.cabinetRepository.adjustQuantity(

View file

@ -21,9 +21,13 @@ vi.mock('../../schemas/purchase.schema.js', () => {
class FakeModel {
data: unknown;
constructor(data: unknown) { this.data = data; }
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() { return this.data; }
toObject() {
return this.data;
}
static find = vi.fn(() => findChain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());
@ -54,7 +58,15 @@ describe(PurchasesRepository.name, () => {
describe('create', () => {
it('saves and returns plain object', async () => {
const data = { householdId: 'hh1', storeId: 'st-1', storeName: 'CVS', status: 'in_cabinet', items: [makeItem()], purchasedAt: new Date(), createdBy: 'u-1' };
const data = {
householdId: 'hh1',
storeId: 'st-1',
storeName: 'CVS',
status: 'in_cabinet',
items: [makeItem()],
purchasedAt: new Date(),
createdBy: 'u-1',
};
mockSave.mockResolvedValue({ toObject: () => data });
const result = await repo.create(data);
@ -104,9 +116,7 @@ describe(PurchasesRepository.name, () => {
await repo.findByHousehold('hh1', { limit: 20, storeId: 'st-1' });
expect(PurchaseModel.find).toHaveBeenCalledWith(
expect.objectContaining({ storeId: 'st-1' }),
);
expect(PurchaseModel.find).toHaveBeenCalledWith(expect.objectContaining({ storeId: 'st-1' }));
});
it('applies cursor filter when provided', async () => {

View file

@ -53,9 +53,7 @@ export class PurchasesRepository {
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0
? Buffer.from(data[data.length - 1]._id.toString()).toString('base64')
: null;
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}

View file

@ -18,21 +18,16 @@ vi.mock('jose', () => ({
}),
}));
const {
mockList,
mockGetById,
mockCreate,
mockUpdate,
mockReceive,
mockDelete,
} = vi.hoisted(() => ({
mockList: vi.fn(),
mockGetById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockReceive: vi.fn(),
mockDelete: vi.fn(),
}));
const { mockList, mockGetById, mockCreate, mockUpdate, mockReceive, mockDelete } = vi.hoisted(
() => ({
mockList: vi.fn(),
mockGetById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockReceive: vi.fn(),
mockDelete: vi.fn(),
}),
);
vi.mock('./purchases.repository.js', () => ({
PurchasesRepository: class {

View file

@ -48,11 +48,7 @@ type AnyPurchase = {
function toItemResponse(item: AnyPurchaseItem) {
return {
_id: item._id
? typeof item._id === 'string'
? item._id
: item._id.toString()
: '',
_id: item._id ? (typeof item._id === 'string' ? item._id : item._id.toString()) : '',
...(item.medicineProductId ? { medicineProductId: item.medicineProductId } : {}),
...(item.medicineId ? { medicineId: item.medicineId } : {}),
...(item.foodProductId ? { foodProductId: item.foodProductId } : {}),

View file

@ -38,7 +38,12 @@ describe(PurchasesService.name, () => {
});
const fakeStore = { _id: 'st-1', name: 'CVS' };
const fakeProduct = { _id: 'mp-1', medicineId: 'med-1', medicineName: 'Ibuprofen', brand: 'Advil' };
const fakeProduct = {
_id: 'mp-1',
medicineId: 'med-1',
medicineName: 'Ibuprofen',
brand: 'Advil',
};
describe('create', () => {
const validInput = {
@ -165,7 +170,9 @@ describe(PurchasesService.name, () => {
it('throws NotFoundError when purchase not found', async () => {
mockPurchasesRepo.findById.mockResolvedValue(null);
await expect(service.receive('missing', 'hh1', 'user-1')).rejects.toThrow('Purchase not found');
await expect(service.receive('missing', 'hh1', 'user-1')).rejects.toThrow(
'Purchase not found',
);
});
it('throws BadRequestError when status is not ordered', async () => {
@ -309,7 +316,14 @@ describe(PurchasesService.name, () => {
storeName: 'CVS',
purchasedAt: new Date(),
items: [
{ medicineProductId: 'mp-1', medicineId: 'med-1', name: 'X', quantity: 10, unit: 'tablet', addedToCabinet: true },
{
medicineProductId: 'mp-1',
medicineId: 'med-1',
name: 'X',
quantity: 10,
unit: 'tablet',
addedToCabinet: true,
},
],
};
mockPurchasesRepo.findById.mockResolvedValue(purchase);

View file

@ -8,7 +8,7 @@ import type {
UpdatePurchaseInput,
PurchaseQueryInput,
} from '@meshitrack/shared';
import { DosageUnit } from '@meshitrack/shared';
import { type DosageUnit } from '@meshitrack/shared';
import { NotFoundError, BadRequestError } from '../../common/errors.js';
interface Deps {
@ -67,7 +67,8 @@ export class PurchasesService {
item.medicineProductId,
householdId,
);
if (!product) throw new NotFoundError(`Medicine product not found: ${item.medicineProductId}`);
if (!product)
throw new NotFoundError(`Medicine product not found: ${item.medicineProductId}`);
if (!resolvedName || resolvedName === item.name) {
resolvedName = product.brand ?? resolvedName;
}
@ -128,7 +129,8 @@ export class PurchasesService {
quantity: item.quantity,
unit: item.unit,
/* v8 ignore next */
pricePerUnit: item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
pricePerUnit:
item.quantity > 0 ? item.actualPrice / item.quantity : item.actualPrice,
date: purchasedAt,
isInsurancePrice: false,
createdBy: userId,
@ -259,9 +261,7 @@ export class PurchasesService {
return deleted;
}
public async getPendingStockByMedicine(
householdId: string,
): Promise<Map<string, number>> {
public async getPendingStockByMedicine(householdId: string): Promise<Map<string, number>> {
const results = await this.purchasesRepository.getPendingMedicineStock(householdId);
const map = new Map<string, number>();
for (const r of results) {

View file

@ -19,9 +19,13 @@ vi.mock('../../schemas/refill-list.schema.js', () => {
class FakeModel {
data: unknown;
constructor(data: unknown) { this.data = data; }
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() { return this.data; }
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());

View file

@ -1,5 +1,9 @@
import { RefillListModel } from '../../schemas/refill-list.schema.js';
import type { RefillListQueryInput, UpdateRefillListInput, UpdateRefillListItemInput } from '@meshitrack/shared';
import type {
RefillListQueryInput,
UpdateRefillListInput,
UpdateRefillListItemInput,
} from '@meshitrack/shared';
export interface CreateRefillListData {
householdId: string;
@ -46,9 +50,7 @@ export class RefillsRepository {
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, limit) : items;
const cursor =
data.length > 0
? Buffer.from(data[data.length - 1]._id.toString()).toString('base64')
: null;
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
}

View file

@ -131,8 +131,20 @@ describe('refills.routes', () => {
dailyConsumption: 2,
currentStock: 6,
suggestedQuantity: 60,
lastKnownPrice: { price: 10, pricePerUnit: 0.1, storeName: 'CVS', storeId: 'st-1', date: new Date('2026-01-01T00:00:00.000Z') },
cheapestOption: { price: 8, pricePerUnit: 0.08, storeName: 'Walmart', storeId: 'st-2', date: new Date('2026-01-02T00:00:00.000Z') },
lastKnownPrice: {
price: 10,
pricePerUnit: 0.1,
storeName: 'CVS',
storeId: 'st-1',
date: new Date('2026-01-01T00:00:00.000Z'),
},
cheapestOption: {
price: 8,
pricePerUnit: 0.08,
storeName: 'Walmart',
storeId: 'st-2',
date: new Date('2026-01-02T00:00:00.000Z'),
},
},
]);
@ -265,26 +277,28 @@ describe('refills.routes', () => {
it('includes optional list fields in response', async () => {
mockList.mockResolvedValue({
data: [makeFakeRefillList({
preferredStoreId: 'st-1',
totalEstimatedCost: 25.5,
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
estimatedPrice: 10,
actualPrice: 9.5,
checked: true,
checkedAt: new Date('2026-01-10T00:00:00.000Z'),
addedToCabinet: false,
storeId: 'st-1',
notes: 'generic brand',
},
],
})],
data: [
makeFakeRefillList({
preferredStoreId: 'st-1',
totalEstimatedCost: 25.5,
items: [
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
estimatedPrice: 10,
actualPrice: 9.5,
checked: true,
checkedAt: new Date('2026-01-10T00:00:00.000Z'),
addedToCabinet: false,
storeId: 'st-1',
notes: 'generic brand',
},
],
}),
],
pagination: { cursor: null, hasMore: false },
});
@ -322,22 +336,24 @@ describe('refills.routes', () => {
});
it('handles ObjectId-style _id in list and items', async () => {
mockGetById.mockResolvedValue(makeFakeRefillList({
_id: { toString: () => 'rl-obj' },
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
items: [
{
_id: { toString: () => 'item-obj' },
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: false,
addedToCabinet: false,
},
],
}));
mockGetById.mockResolvedValue(
makeFakeRefillList({
_id: { toString: () => 'rl-obj' },
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
items: [
{
_id: { toString: () => 'item-obj' },
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: false,
addedToCabinet: false,
},
],
}),
);
const res = await app.inject({
method: 'GET',

View file

@ -45,7 +45,13 @@ describe(RefillsService.name, () => {
describe('getAlerts', () => {
it('returns empty array when no medicines are running low', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 1, totalInCabinet: 100, daysUntilEmpty: 100 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 1,
totalInCabinet: 100,
daysUntilEmpty: 100,
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
@ -55,7 +61,13 @@ describe(RefillsService.name, () => {
it('returns alerts for medicines below threshold', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 10, daysUntilEmpty: 5 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 10,
daysUntilEmpty: 5,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([
{ _id: 'med-1', medicineStrength: 500, medicineStrengthUnit: 'mg' },
@ -73,7 +85,13 @@ describe(RefillsService.name, () => {
it('attaches lastKnownPrice when available', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 5,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue({
@ -93,12 +111,25 @@ describe(RefillsService.name, () => {
it('attaches cheapestOption from compareStores', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 5,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([
{ storeId: 'st-1', storeName: 'CVS', latestPrice: 8, latestPricePerUnit: 0.08, currency: 'USD', date: new Date() },
{
storeId: 'st-1',
storeName: 'CVS',
latestPrice: 8,
latestPricePerUnit: 0.08,
currency: 'USD',
date: new Date(),
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
@ -109,7 +140,13 @@ describe(RefillsService.name, () => {
it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 4, daysUntilEmpty: 2 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 4,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
@ -126,7 +163,13 @@ describe(RefillsService.name, () => {
it('excludes medicines with null daysUntilEmpty', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 0, daysUntilEmpty: null },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 0,
daysUntilEmpty: null,
},
]);
const result = await service.getAlerts('hh1', 'user-1', 7);
@ -145,7 +188,9 @@ describe(RefillsService.name, () => {
name: 'My List',
fromAlerts: false,
thresholdDays: 7,
items: [{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never }],
items: [
{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never },
],
},
'hh1',
'user-1',
@ -160,7 +205,11 @@ describe(RefillsService.name, () => {
it('creates list with no items when neither fromAlerts nor items provided', async () => {
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
await service.createList({ name: 'Empty List', fromAlerts: false, thresholdDays: 7 }, 'hh1', 'user-1');
await service.createList(
{ name: 'Empty List', fromAlerts: false, thresholdDays: 7 },
'hh1',
'user-1',
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ items: [], totalEstimatedCost: undefined }),
@ -176,8 +225,20 @@ describe(RefillsService.name, () => {
fromAlerts: false,
thresholdDays: 7,
items: [
{ medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet' as never, estimatedPrice: 10 },
{ medicineId: 'med-2', medicineName: 'Ibuprofen', quantity: 20, unit: 'tablet' as never, estimatedPrice: 8 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet' as never,
estimatedPrice: 10,
},
{
medicineId: 'med-2',
medicineName: 'Ibuprofen',
quantity: 20,
unit: 'tablet' as never,
estimatedPrice: 8,
},
],
},
'hh1',
@ -191,20 +252,28 @@ describe(RefillsService.name, () => {
it('creates list from alerts when fromAlerts is true', async () => {
mockRegimensService.calculateBurnRates.mockResolvedValue([
{ medicineId: 'med-1', medicineName: 'Aspirin', dailyConsumption: 2, totalInCabinet: 5, daysUntilEmpty: 2 },
{
medicineId: 'med-1',
medicineName: 'Aspirin',
dailyConsumption: 2,
totalInCabinet: 5,
daysUntilEmpty: 2,
},
]);
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
mockPricesRepo.compareStores.mockResolvedValue([]);
mockRepo.create.mockResolvedValue({ _id: 'rl-1', items: [] });
await service.createList({ name: 'Auto List', fromAlerts: true, thresholdDays: 7 }, 'hh1', 'user-1');
await service.createList(
{ name: 'Auto List', fromAlerts: true, thresholdDays: 7 },
'hh1',
'user-1',
);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ medicineId: 'med-1' }),
]),
items: expect.arrayContaining([expect.objectContaining({ medicineId: 'med-1' })]),
}),
);
});
@ -251,7 +320,9 @@ describe(RefillsService.name, () => {
it('throws NotFoundError when list not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow('Refill list not found');
await expect(service.updateList('missing', 'hh1', {})).rejects.toThrow(
'Refill list not found',
);
});
it('throws NotFoundError when update returns null', async () => {
@ -290,7 +361,9 @@ describe(RefillsService.name, () => {
it('throws NotFoundError when list not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.updateItem('missing', 'hh1', 'item-1', {})).rejects.toThrow('Refill list not found');
await expect(service.updateItem('missing', 'hh1', 'item-1', {})).rejects.toThrow(
'Refill list not found',
);
});
it('throws NotFoundError when item not found', async () => {
@ -308,7 +381,15 @@ describe(RefillsService.name, () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{ _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: false },
{
_id: { toString: () => 'item-1' },
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: false,
},
],
});
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
@ -324,7 +405,16 @@ describe(RefillsService.name, () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{ _id: { toString: () => 'item-1' }, medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', actualPrice: 9, checked: true, addedToCabinet: false },
{
_id: { toString: () => 'item-1' },
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
actualPrice: 9,
checked: true,
addedToCabinet: false,
},
],
});
mockCabinetService.addItem.mockResolvedValue({ _id: 'ci-1' });
@ -343,7 +433,15 @@ describe(RefillsService.name, () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{ _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: false, addedToCabinet: false },
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: false,
addedToCabinet: false,
},
],
});
@ -357,7 +455,15 @@ describe(RefillsService.name, () => {
mockRepo.findById.mockResolvedValue({
_id: 'rl-1',
items: [
{ _id: 'item-1', medicineId: 'med-1', medicineName: 'Aspirin', quantity: 30, unit: 'tablet', checked: true, addedToCabinet: true },
{
_id: 'item-1',
medicineId: 'med-1',
medicineName: 'Aspirin',
quantity: 30,
unit: 'tablet',
checked: true,
addedToCabinet: true,
},
],
});

View file

@ -9,7 +9,6 @@ import type {
UpdateRefillListInput,
UpdateRefillListItemInput,
RefillListQueryInput,
RefillAlertQueryInput,
} from '@meshitrack/shared';
import { RefillListStatus } from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
@ -58,7 +57,10 @@ export class RefillsService {
// Get strength data from cabinet aggregate
const summaries = await this.cabinetRepository.getAggregateSummary(householdId);
const summaryMap = new Map<string, { medicineStrength: number; medicineStrengthUnit: string }>();
const summaryMap = new Map<
string,
{ medicineStrength: number; medicineStrengthUnit: string }
>();
for (const s of summaries) {
summaryMap.set(s._id as string, {
medicineStrength: s.medicineStrength as number,
@ -217,17 +219,19 @@ export class RefillsService {
public async addToCabinet(listId: string, householdId: string, userId: string) {
const list = await this.getById(listId, householdId);
const checkedItems = (list.items as Array<{
_id: { toString: () => string };
medicineId: string;
medicineName: string;
quantity: number;
unit: string;
actualPrice?: number;
storeId?: string;
checked: boolean;
addedToCabinet: boolean;
}>).filter((item) => item.checked && !item.addedToCabinet);
const checkedItems = (
list.items as Array<{
_id: { toString: () => string };
medicineId: string;
medicineName: string;
quantity: number;
unit: string;
actualPrice?: number;
storeId?: string;
checked: boolean;
addedToCabinet: boolean;
}>
).filter((item) => item.checked && !item.addedToCabinet);
if (checkedItems.length === 0) {
return { addedCount: 0, priceRecordsCreated: 0 };
@ -242,9 +246,10 @@ export class RefillsService {
medicineId: item.medicineId,
quantity: item.quantity,
unit: item.unit as never,
unitPrice: item.actualPrice !== undefined && item.quantity > 0
? item.actualPrice / item.quantity
: undefined,
unitPrice:
item.actualPrice !== undefined && item.quantity > 0
? item.actualPrice / item.quantity
: undefined,
totalPrice: item.actualPrice,
storeId: item.storeId,
purchaseDate: new Date().toISOString(),
@ -265,9 +270,7 @@ export class RefillsService {
const list = await this.getById(listId, householdId);
const medicineIds = [
...new Set(
(list.items as Array<{ medicineId: string }>).map((item) => item.medicineId),
),
...new Set((list.items as Array<{ medicineId: string }>).map((item) => item.medicineId)),
];
const comparisons = await Promise.all(

View file

@ -68,7 +68,12 @@ export class RegimensRepository {
.exec();
}
public async create(data: CreateRegimenData, householdId: string, userId: string, createdBy: string) {
public async create(
data: CreateRegimenData,
householdId: string,
userId: string,
createdBy: string,
) {
const regimen = new RegimenModel({ ...data, householdId, userId, createdBy });
const saved = await regimen.save();
return saved.toObject();

View file

@ -19,14 +19,15 @@ vi.mock('jose', () => ({
}),
}));
const { mockList, mockGetById, mockCreate, mockUpdate, mockDelete, mockCalculateBurnRates } = vi.hoisted(() => ({
mockList: vi.fn(),
mockGetById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockCalculateBurnRates: vi.fn(),
}));
const { mockList, mockGetById, mockCreate, mockUpdate, mockDelete, mockCalculateBurnRates } =
vi.hoisted(() => ({
mockList: vi.fn(),
mockGetById: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
mockCalculateBurnRates: vi.fn(),
}));
vi.mock('./regimens.repository.js', () => ({
RegimensRepository: class {
@ -467,7 +468,12 @@ describe('regimens.routes', () => {
payload: { isActive: false },
});
expect(mockUpdate).toHaveBeenCalledWith('reg-1', 'hh1', 'kc-1', expect.objectContaining({ isActive: false }));
expect(mockUpdate).toHaveBeenCalledWith(
'reg-1',
'hh1',
'kc-1',
expect.objectContaining({ isActive: false }),
);
});
});

View file

@ -65,7 +65,9 @@ function toRegimenResponse(doc: AnyRegimenDoc) {
dosage: med.dosage,
dosageUnit: med.dosageUnit,
frequency: med.frequency,
...(med.customFrequencyPerDay != null ? { customFrequencyPerDay: med.customFrequencyPerDay } : {}),
...(med.customFrequencyPerDay != null
? { customFrequencyPerDay: med.customFrequencyPerDay }
: {}),
...(med.timeOfDay ? { timeOfDay: med.timeOfDay } : {}),
...(med.instructions ? { instructions: med.instructions } : {}),
})),
@ -143,7 +145,11 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
const regimen = await service.getById(request.params.id, request.params.householdId, request.user.keycloakId);
const regimen = await service.getById(
request.params.id,
request.params.householdId,
request.user.keycloakId,
);
return reply.send(toRegimenResponse(regimen));
},
});
@ -199,7 +205,11 @@ export default fp(
},
handler: async (request, reply) => {
const service = fastify.diContainer.resolve('regimensService');
await service.delete(request.params.id, request.params.householdId, request.user.keycloakId);
await service.delete(
request.params.id,
request.params.householdId,
request.user.keycloakId,
);
return reply.status(204).send();
},
});

View file

@ -82,7 +82,9 @@ describe(RegimensService.name, () => {
it('throws NotFoundError when not found', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.getById('reg-missing', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
await expect(service.getById('reg-missing', 'hh1', 'user-1')).rejects.toThrow(
'Regimen not found',
);
});
});
@ -108,7 +110,11 @@ describe(RegimensService.name, () => {
strengthUnit: 'mg',
form: 'tablet',
});
const created = { _id: 'reg-1', ...createInput, medications: [{ medicineId: 'med-1', medicineName: 'Metformin' }] };
const created = {
_id: 'reg-1',
...createInput,
medications: [{ medicineId: 'med-1', medicineName: 'Metformin' }],
};
mockRegimensRepo.create.mockResolvedValue(created);
const result = await service.create(createInput, 'hh1', 'user-1');
@ -255,7 +261,9 @@ describe(RegimensService.name, () => {
const result = await service.update('reg-1', 'hh1', 'user-1', { name: 'Evening' });
expect(result).toEqual(updated);
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { name: 'Evening' });
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {
name: 'Evening',
});
});
it('updates isActive only', async () => {
@ -264,7 +272,9 @@ describe(RegimensService.name, () => {
await service.update('reg-1', 'hh1', 'user-1', { isActive: false });
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', { isActive: false });
expect(mockRegimensRepo.update).toHaveBeenCalledWith('reg-1', 'hh1', 'user-1', {
isActive: false,
});
});
it('updates medications with denormalization', async () => {
@ -304,9 +314,9 @@ describe(RegimensService.name, () => {
it('throws NotFoundError when regimen not found on initial lookup', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' })).rejects.toThrow(
'Regimen not found',
);
await expect(
service.update('reg-missing', 'hh1', 'user-1', { name: 'Updated' }),
).rejects.toThrow('Regimen not found');
});
it('throws NotFoundError when update returns null', async () => {
@ -360,7 +370,9 @@ describe(RegimensService.name, () => {
it('throws NotFoundError when regimen not found on initial lookup', async () => {
mockRegimensRepo.findById.mockResolvedValue(null);
await expect(service.delete('reg-missing', 'hh1', 'user-1')).rejects.toThrow('Regimen not found');
await expect(service.delete('reg-missing', 'hh1', 'user-1')).rejects.toThrow(
'Regimen not found',
);
});
it('throws NotFoundError when softDelete returns null', async () => {
@ -444,13 +456,23 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY },
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
{
_id: 'reg-2',
medications: [
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.TWICE_DAILY },
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.TWICE_DAILY,
},
],
},
]);
@ -479,7 +501,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Metformin', dosage: 1, frequency: DosageFrequency.DAILY },
{
medicineId: 'med-1',
medicineName: 'Metformin',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
]);
@ -498,7 +525,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Ibuprofen', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{
medicineId: 'med-1',
medicineName: 'Ibuprofen',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
],
},
]);
@ -562,9 +594,24 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-3',
medicineName: 'Med C',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
],
},
]);
@ -590,8 +637,18 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
],
},
]);
@ -606,9 +663,24 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.AS_NEEDED },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-3', medicineName: 'Med C', dosage: 1, frequency: DosageFrequency.DAILY },
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.AS_NEEDED,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-3',
medicineName: 'Med C',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
],
},
]);
@ -634,7 +706,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Weekly Med', dosage: 1, frequency: DosageFrequency.WEEKLY },
{
medicineId: 'med-1',
medicineName: 'Weekly Med',
dosage: 1,
frequency: DosageFrequency.WEEKLY,
},
],
},
]);
@ -656,7 +733,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'EOD Med', dosage: 1, frequency: DosageFrequency.EVERY_OTHER_DAY },
{
medicineId: 'med-1',
medicineName: 'EOD Med',
dosage: 1,
frequency: DosageFrequency.EVERY_OTHER_DAY,
},
],
},
]);
@ -678,7 +760,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'TID Med', dosage: 1, frequency: DosageFrequency.THREE_TIMES_DAILY },
{
medicineId: 'med-1',
medicineName: 'TID Med',
dosage: 1,
frequency: DosageFrequency.THREE_TIMES_DAILY,
},
],
},
]);
@ -699,7 +786,12 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Expensive Med', dosage: 2, frequency: DosageFrequency.DAILY },
{
medicineId: 'med-1',
medicineName: 'Expensive Med',
dosage: 2,
frequency: DosageFrequency.DAILY,
},
],
},
]);
@ -725,8 +817,18 @@ describe(RegimensService.name, () => {
{
_id: 'reg-1',
medications: [
{ medicineId: 'med-1', medicineName: 'Med A', dosage: 1, frequency: DosageFrequency.DAILY },
{ medicineId: 'med-2', medicineName: 'Med B', dosage: 1, frequency: DosageFrequency.TWICE_DAILY },
{
medicineId: 'med-1',
medicineName: 'Med A',
dosage: 1,
frequency: DosageFrequency.DAILY,
},
{
medicineId: 'med-2',
medicineName: 'Med B',
dosage: 1,
frequency: DosageFrequency.TWICE_DAILY,
},
],
},
]);
@ -737,9 +839,7 @@ describe(RegimensService.name, () => {
]);
mockCabinetEventsService.getAvgUnitPrices.mockResolvedValue(
new Map([
['med-1', { avgUnitPrice: 2.0, currency: 'USD' }],
]),
new Map([['med-1', { avgUnitPrice: 2.0, currency: 'USD' }]]),
);
const result = await service.calculateBurnRates('hh1', 'user-1');

View file

@ -82,10 +82,7 @@ export class RegimensService {
const regimens = await this.regimensRepository.findActiveByUser(householdId, userId);
// Sum daily consumption per medicine across all active regimens
const consumptionMap = new Map<
string,
{ medicineName: string; dailyConsumption: number }
>();
const consumptionMap = new Map<string, { medicineName: string; dailyConsumption: number }>();
for (const regimen of regimens) {
for (const med of regimen.medications) {
@ -115,10 +112,7 @@ export class RegimensService {
// Get cabinet summary for all medicines in regimens
const summaryResults = await this.cabinetRepository.getAggregateSummary(householdId);
const stockMap = new Map<
string,
{ totalQuantity: number; earliestExpiry: Date | null }
>();
const stockMap = new Map<string, { totalQuantity: number; earliestExpiry: Date | null }>();
for (const s of summaryResults) {
stockMap.set(s._id as string, {
totalQuantity: s.totalQuantity as number,

View file

@ -19,9 +19,13 @@ vi.mock('../../schemas/store.schema.js', () => {
class FakeModel {
data: unknown;
constructor(data: unknown) { this.data = data; }
constructor(data: unknown) {
this.data = data;
}
save = mockSave;
toObject() { return this.data; }
toObject() {
return this.data;
}
static find = vi.fn(() => chain());
static findOne = vi.fn(() => findOneChain());
static findOneAndUpdate = vi.fn(() => updateChain());

View file

@ -6,7 +6,10 @@ export class StoresRepository {
const filter: Record<string, unknown> = { householdId };
if (query.tags) {
const tagList = query.tags.split(',').map((t) => t.trim()).filter(Boolean);
const tagList = query.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean);
if (tagList.length > 0) filter['tags'] = { $in: tagList };
}

View file

@ -140,11 +140,13 @@ describe('stores.routes', () => {
it('handles ObjectId and Date in response', async () => {
mockList.mockResolvedValue({
data: [makeFakeStore({
_id: { toString: () => 'st-obj' },
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
})],
data: [
makeFakeStore({
_id: { toString: () => 'st-obj' },
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
}),
],
pagination: { cursor: null, hasMore: false },
});
@ -162,12 +164,14 @@ describe('stores.routes', () => {
it('includes optional fields in response when present', async () => {
mockList.mockResolvedValue({
data: [makeFakeStore({
address: '123 Main St',
location: { lat: 40.7128, lng: -74.006 },
url: 'https://walgreens.com',
notes: 'Open 24h',
})],
data: [
makeFakeStore({
address: '123 Main St',
location: { lat: 40.7128, lng: -74.006 },
url: 'https://walgreens.com',
notes: 'Open 24h',
}),
],
pagination: { cursor: null, hasMore: false },
});

View file

@ -49,7 +49,11 @@ describe(StoresService.name, () => {
const store = { _id: 'st-1', name: 'CVS' };
mockRepo.create.mockResolvedValue(store);
const result = await service.create({ name: 'CVS', tags: [], isActive: true } as never, 'hh1', 'user-1');
const result = await service.create(
{ name: 'CVS', tags: [], isActive: true } as never,
'hh1',
'user-1',
);
expect(result).toEqual(store);
expect(mockRepo.create).toHaveBeenCalledWith(expect.anything(), 'hh1', 'user-1');

View file

@ -67,9 +67,7 @@ async function migrate() {
}
}
if (dirty) {
await db
.collection('regimens')
.updateOne({ _id: regimen._id }, { $set: { medications } });
await db.collection('regimens').updateOne({ _id: regimen._id }, { $set: { medications } });
console.log(`regimens: updated medications in regimen ${regimen._id}`);
total++;
}
@ -88,9 +86,7 @@ async function migrate() {
}
}
if (dirty) {
await db
.collection('refillists')
.updateOne({ _id: list._id }, { $set: { items } });
await db.collection('refillists').updateOne({ _id: list._id }, { $set: { items } });
console.log(`refillists: updated items in list ${list._id}`);
total++;
}

View file

@ -1,8 +1,5 @@
import { describe, it, expect } from 'vitest';
import {
CabinetEventType,
CabinetEventSourceType,
} from './cabinet-event.enums.js';
import { CabinetEventType, CabinetEventSourceType } from './cabinet-event.enums.js';
describe(CabinetEventType.name, () => {
it('has exactly 6 values', () => {
@ -32,8 +29,6 @@ describe(CabinetEventSourceType.name, () => {
['ORGANIZER_UNDO', 'organizer_undo'],
['REFILL_LIST', 'refill_list'],
])('%s = %s', (key, value) => {
expect(
CabinetEventSourceType[key as keyof typeof CabinetEventSourceType],
).toBe(value);
expect(CabinetEventSourceType[key as keyof typeof CabinetEventSourceType]).toBe(value);
});
});

View file

@ -1,9 +1,5 @@
import { describe, it, expect } from 'vitest';
import {
DosageFrequency,
TimeOfDay,
OrganizerFillStatus,
} from './regimen.enums.js';
import { DosageFrequency, TimeOfDay, OrganizerFillStatus } from './regimen.enums.js';
describe(DosageFrequency.name, () => {
it('has exactly 7 values', () => {
@ -48,8 +44,6 @@ describe(OrganizerFillStatus.name, () => {
['PARTIAL', 'partial'],
['REVERSED', 'reversed'],
])('%s = %s', (key, value) => {
expect(
OrganizerFillStatus[key as keyof typeof OrganizerFillStatus],
).toBe(value);
expect(OrganizerFillStatus[key as keyof typeof OrganizerFillStatus]).toBe(value);
});
});

View file

@ -1,7 +1,4 @@
import type {
CabinetEventType,
CabinetEventSourceType,
} from '../enums/cabinet-event.enums.js';
import type { CabinetEventType, CabinetEventSourceType } from '../enums/cabinet-event.enums.js';
export interface CabinetEvent {
id: string;

View file

@ -1,7 +1,4 @@
import type {
DosageFrequency,
TimeOfDay,
} from '../enums/regimen.enums.js';
import type { DosageFrequency, TimeOfDay } from '../enums/regimen.enums.js';
import type { DosageUnit, MedicineForm, StrengthUnit } from '../enums/medicine.enums.js';
export interface Regimen {

View file

@ -5,10 +5,7 @@ import { DosageFrequency } from '../enums/regimen.enums.js';
* For example, TWICE_DAILY returns 2, WEEKLY returns 1/7.
* AS_NEEDED returns 0 (excluded from calculations).
*/
export function getFrequencyMultiplier(
frequency: DosageFrequency,
customPerDay?: number,
): number {
export function getFrequencyMultiplier(frequency: DosageFrequency, customPerDay?: number): number {
switch (frequency) {
case DosageFrequency.DAILY:
return 1;

View file

@ -35,15 +35,21 @@ describe('CreateMedicinePriceRecordSchema', () => {
});
it('rejects non-positive price', () => {
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, price: 0 }).success).toBe(false);
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, price: 0 }).success).toBe(
false,
);
});
it('rejects non-positive quantity', () => {
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, quantity: -1 }).success).toBe(false);
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, quantity: -1 }).success).toBe(
false,
);
});
it('rejects invalid unit', () => {
expect(CreateMedicinePriceRecordSchema.safeParse({ ...validInput, unit: 'spoon' }).success).toBe(false);
expect(
CreateMedicinePriceRecordSchema.safeParse({ ...validInput, unit: 'spoon' }).success,
).toBe(false);
});
});

View file

@ -71,26 +71,32 @@ export const StoreComparisonResponseSchema = z.object({
export const MedicineSpendingAnalyticsResponseSchema = z.object({
spendingOverTime: z.array(z.object({ period: z.string(), total: z.number() })),
topBySpending: z.array(z.object({
medicineId: z.string(),
medicineName: z.string(),
totalSpent: z.number(),
avgPricePerUnit: z.number(),
})),
spendingByStore: z.array(z.object({
storeId: z.string(),
storeName: z.string(),
totalSpent: z.number(),
purchaseCount: z.number(),
})),
priceAlerts: z.array(z.object({
medicineId: z.string(),
medicineName: z.string(),
storeName: z.string(),
previousPrice: z.number(),
currentPrice: z.number(),
changePercent: z.number(),
})),
topBySpending: z.array(
z.object({
medicineId: z.string(),
medicineName: z.string(),
totalSpent: z.number(),
avgPricePerUnit: z.number(),
}),
),
spendingByStore: z.array(
z.object({
storeId: z.string(),
storeName: z.string(),
totalSpent: z.number(),
purchaseCount: z.number(),
}),
),
priceAlerts: z.array(
z.object({
medicineId: z.string(),
medicineName: z.string(),
storeName: z.string(),
previousPrice: z.number(),
currentPrice: z.number(),
changePercent: z.number(),
}),
),
});
export type CreateMedicinePriceRecordInput = z.infer<typeof CreateMedicinePriceRecordSchema>;

View file

@ -8,7 +8,11 @@ import {
describe('CreatePurchaseItemSchema', () => {
it('accepts minimal valid item', () => {
const result = CreatePurchaseItemSchema.safeParse({ name: 'Tylenol 30ct', quantity: 30, unit: 'tablet' });
const result = CreatePurchaseItemSchema.safeParse({
name: 'Tylenol 30ct',
quantity: 30,
unit: 'tablet',
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.addedToCabinet).toBeUndefined();
});
@ -27,19 +31,29 @@ describe('CreatePurchaseItemSchema', () => {
});
it('rejects empty name', () => {
expect(CreatePurchaseItemSchema.safeParse({ name: '', quantity: 1, unit: 'tablet' }).success).toBe(false);
expect(
CreatePurchaseItemSchema.safeParse({ name: '', quantity: 1, unit: 'tablet' }).success,
).toBe(false);
});
it('rejects non-positive quantity', () => {
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 0, unit: 'tablet' }).success).toBe(false);
expect(
CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 0, unit: 'tablet' }).success,
).toBe(false);
});
it('rejects empty unit', () => {
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 1, unit: '' }).success).toBe(false);
expect(CreatePurchaseItemSchema.safeParse({ name: 'X', quantity: 1, unit: '' }).success).toBe(
false,
);
});
it('trims name whitespace', () => {
const result = CreatePurchaseItemSchema.parse({ name: ' Tylenol ', quantity: 1, unit: 'tablet' });
const result = CreatePurchaseItemSchema.parse({
name: ' Tylenol ',
quantity: 1,
unit: 'tablet',
});
expect(result.name).toBe('Tylenol');
});
});
@ -53,7 +67,11 @@ describe('CreatePurchaseSchema', () => {
});
it('accepts ordered status', () => {
const result = CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'ordered', items: [validItem] });
const result = CreatePurchaseSchema.safeParse({
storeId: 'st-1',
status: 'ordered',
items: [validItem],
});
expect(result.success).toBe(true);
});
@ -66,7 +84,10 @@ describe('CreatePurchaseSchema', () => {
});
it('rejects invalid status', () => {
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'pending', items: [validItem] }).success).toBe(false);
expect(
CreatePurchaseSchema.safeParse({ storeId: 'st-1', status: 'pending', items: [validItem] })
.success,
).toBe(false);
});
it('accepts optional purchasedAt datetime', () => {
@ -79,7 +100,13 @@ describe('CreatePurchaseSchema', () => {
});
it('rejects invalid purchasedAt', () => {
expect(CreatePurchaseSchema.safeParse({ storeId: 'st-1', items: [validItem], purchasedAt: 'not-a-date' }).success).toBe(false);
expect(
CreatePurchaseSchema.safeParse({
storeId: 'st-1',
items: [validItem],
purchasedAt: 'not-a-date',
}).success,
).toBe(false);
});
});

View file

@ -81,8 +81,8 @@ describe('UpdateRefillListItemSchema', () => {
});
it('accepts actualPrice', () => {
const result = UpdateRefillListItemSchema.parse({ actualPrice: 12.50 });
expect(result.actualPrice).toBe(12.50);
const result = UpdateRefillListItemSchema.parse({ actualPrice: 12.5 });
expect(result.actualPrice).toBe(12.5);
});
it('rejects negative actualPrice', () => {

View file

@ -102,10 +102,7 @@ describe('CreateRegimenSchema', () => {
it('accepts multiple medications', () => {
const result = CreateRegimenSchema.safeParse({
...validRegimen,
medications: [
validMedication,
{ ...validMedication, medicineId: 'med-2', dosage: 2 },
],
medications: [validMedication, { ...validMedication, medicineId: 'med-2', dosage: 2 }],
});
expect(result.success).toBe(true);
});

View file

@ -15,9 +15,7 @@ export const RegimenMedicationInputSchema = z
instructions: z.string().max(500).trim().optional(),
})
.refine(
(data) =>
data.frequency !== DosageFrequency.CUSTOM ||
data.customFrequencyPerDay !== undefined,
(data) => data.frequency !== DosageFrequency.CUSTOM || data.customFrequencyPerDay !== undefined,
{ message: 'customFrequencyPerDay is required when frequency is custom' },
);

View file

@ -29,15 +29,15 @@ describe('CreateStoreSchema', () => {
});
it('rejects invalid location lat', () => {
expect(
CreateStoreSchema.safeParse({ name: 'X', location: { lat: 91, lng: 0 } }).success,
).toBe(false);
expect(CreateStoreSchema.safeParse({ name: 'X', location: { lat: 91, lng: 0 } }).success).toBe(
false,
);
});
it('rejects invalid location lng', () => {
expect(
CreateStoreSchema.safeParse({ name: 'X', location: { lat: 0, lng: 181 } }).success,
).toBe(false);
expect(CreateStoreSchema.safeParse({ name: 'X', location: { lat: 0, lng: 181 } }).success).toBe(
false,
);
});
it('trims name whitespace', () => {

View file

@ -26,6 +26,12 @@ export default tseslint.config(
settings: {
react: { version: 'detect' },
},
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
project: ['./tsconfig.json'],
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit' }],

View file

@ -8,11 +8,11 @@ const nextConfig: NextConfig = {
webpack: (config) => {
// The shared package source uses ESM `.js` extensions on imports (e.g. `./enums/index.js`).
// When Next.js resolves via tsconfig paths to the raw `.ts` source, webpack needs to
// know that `.js` imports inside that directory should resolve to `.ts` files.
// know that `.js` imports inside that directory should resolve to `.ts`/`.tsx` files.
config.resolve = config.resolve ?? {};
config.resolve.extensionAlias = {
...config.resolve.extensionAlias,
'.js': ['.ts', '.js'],
'.js': ['.tsx', '.ts', '.jsx', '.js'],
};
// Ensure the shared source directory is included in the module resolution

View file

@ -13,9 +13,11 @@ import DashboardLayout from '../layout';
describe('DashboardLayout', () => {
it('renders sidebar, topbar and children', () => {
render(<DashboardLayout>
<div data-testid="child">content</div>
</DashboardLayout>);
render(
<DashboardLayout>
<div data-testid="child">content</div>
</DashboardLayout>,
);
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('topbar')).toBeInTheDocument();

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { render, container } from '@testing-library/react';
import { render } from '@testing-library/react';
import DashboardLoading from '../loading';

View file

@ -1,6 +1,9 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('swr', () => ({ default: vi.fn(() => ({ data: undefined })) }));
vi.mock('next/link', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: (props: any) => <a href={props.href}>{props.children}</a>,
@ -8,21 +11,29 @@ vi.mock('next/link', () => ({
import DashboardPage from '../page';
describe('DashboardPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseApi.mockReturnValue({ householdId: null, isLoading: true, profile: undefined });
});
describe(DashboardPage.name, () => {
it('renders heading', () => {
render(<DashboardPage />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
it('renders Medicines card linking to /medicines', () => {
it('shows loading skeleton when session loading', () => {
render(<DashboardPage />);
const link = screen.getByRole('link', { name: /medicines/i });
expect(link).toHaveAttribute('href', '/medicines');
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
it('renders Settings card linking to /settings', () => {
it('renders page when household loaded', () => {
mockUseApi.mockReturnValue({
householdId: 'hh1',
isLoading: false,
profile: { displayName: 'Alice' },
});
render(<DashboardPage />);
const link = screen.getByRole('link', { name: /settings/i });
expect(link).toHaveAttribute('href', '/settings');
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
});

View file

@ -1,41 +1,488 @@
import Link from 'next/link';
'use client';
import useSWR from 'swr';
import { useApi } from '@/lib/useApi';
import { getCabinetSummary, listCabinetItems } from '@/services/cabinet';
import { listPurchases } from '@/services/purchases';
import { getRefillAlerts } from '@/services/refills';
import { listCabinetEvents } from '@/services/cabinet-events';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Pill } from '@/components/ui/Pill';
import { Icon } from '@/components/ui/Icon';
function now() {
return new Date();
}
function greeting() {
const h = now().getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
function formatDate(d: Date) {
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
}
export default function DashboardPage() {
const { householdId, profile, isLoading } = useApi();
const name = profile?.displayName?.split(' ')[0] ?? 'there';
const { data: summary } = useSWR(householdId ? `cabinet-summary-${householdId}` : null, () =>
getCabinetSummary(householdId!),
);
const { data: cabinetItems } = useSWR(householdId ? `cabinet-items-${householdId}` : null, () =>
listCabinetItems(householdId!, { limit: 10 }),
);
const { data: pendingPurchases } = useSWR(
householdId ? `purchases-ordered-${householdId}` : null,
() => listPurchases(householdId!, { status: 'ordered', limit: 5 }),
);
const { data: refillAlerts } = useSWR(householdId ? `refill-alerts-${householdId}` : null, () =>
getRefillAlerts(householdId!, { thresholdDays: 14 }),
);
const { data: recentEvents } = useSWR(householdId ? `cabinet-events-${householdId}` : null, () =>
listCabinetEvents(householdId!, { limit: 5 }),
);
const today = formatDate(now());
if (isLoading) {
return (
<>
<SetPageHeader title="Dashboard" subtitle="Household overview" />
<DashboardSkeleton />
</>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<DashboardCard
title="Medicines"
description="Manage your medicines, products and inventory"
href="/medicines"
/>
<DashboardCard
title="Settings"
description="Manage household and account settings"
href="/settings"
/>
<>
<SetPageHeader title="Dashboard" subtitle="Household overview" />
<div style={{ padding: '28px 32px 56px', maxWidth: 1400, width: '100%' }}>
{/* Hero */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 24,
paddingBottom: 24,
borderBottom: '1px solid var(--border)',
marginBottom: 20,
}}
>
<div>
<div
style={{
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--ink-muted)',
fontWeight: 500,
marginBottom: 6,
}}
>
{today}
</div>
<div
style={{
fontFamily: 'var(--font-display)',
fontSize: 34,
fontWeight: 400,
letterSpacing: '-0.02em',
color: 'var(--ink-strong)',
lineHeight: 1.05,
}}
>
{greeting()}, {name}.
</div>
{summary && (
<div style={{ fontSize: 14, color: 'var(--ink-muted)', marginTop: 8 }}>
{refillAlerts && refillAlerts.data.some((a) => a.daysUntilEmpty <= 7) ? (
<span style={{ color: 'var(--danger)' }}>
Some medicines are critically low check refills.
</span>
) : (
'Your cabinet is in good shape.'
)}
</div>
)}
</div>
{/* Cabinet stats */}
{summary && (
<div style={{ display: 'flex', gap: 16, flexShrink: 0 }}>
<StatBadge label="Total medicines" value={summary.data.length} />
<StatBadge label="Running low" value={refillAlerts?.data.length ?? 0} tone="warn" />
</div>
)}
</div>
{/* Grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(12, 1fr)',
gap: 16,
}}
>
{/* Days of supply */}
<div style={{ gridColumn: 'span 7' }}>
<Card>
<CardHeader
title="Cabinet — days of supply"
subtitle="At current usage"
action={
<Button variant="ghost" size="sm">
Open cabinet <Icon name="arrow" size={12} />
</Button>
}
/>
<div
style={{
padding: '4px 18px 16px',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
{cabinetItems?.data.length ? (
cabinetItems.data.slice(0, 8).map((item) => (
<div
key={item._id}
style={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 12,
alignItems: 'center',
fontSize: 12,
padding: '4px 0',
}}
>
<div
style={{
fontWeight: 500,
color: 'var(--ink)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.medicineName ?? 'Unknown'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
flex: 1,
height: 6,
background: 'var(--bg-inset)',
borderRadius: 3,
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${Math.min(100, (item.quantity / 100) * 100)}%`,
background: 'var(--brand)',
borderRadius: 3,
}}
/>
</div>
<span
className="num"
style={{
fontSize: 12,
fontWeight: 600,
minWidth: 40,
textAlign: 'right',
}}
>
{item.quantity} {item.unit}
</span>
</div>
</div>
))
) : (
<EmptyState message="No cabinet items yet." />
)}
</div>
</Card>
</div>
{/* Running low */}
<div style={{ gridColumn: 'span 5' }}>
<Card>
<CardHeader
title="Running low"
subtitle={`${refillAlerts?.data.length ?? 0} need attention`}
action={
<Button variant="ghost" size="sm">
Refills
</Button>
}
/>
<div
style={{
padding: '4px 18px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
{refillAlerts?.data.length ? (
refillAlerts.data.slice(0, 5).map((alert) => (
<div
key={alert.medicineId}
style={{
display: 'flex',
gap: 10,
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px dashed var(--border)',
}}
>
<div
style={{
width: 22,
height: 22,
borderRadius: 6,
display: 'grid',
placeItems: 'center',
background: 'var(--brand-soft)',
color: 'var(--brand)',
flexShrink: 0,
}}
>
<Icon name="pill" size={12} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: 'var(--ink-strong)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{alert.medicineName}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div
className="num"
style={{
fontSize: 14,
fontWeight: 600,
color: alert.daysUntilEmpty <= 7 ? 'var(--danger)' : 'var(--warn)',
}}
>
{alert.daysUntilEmpty}d
</div>
<div style={{ fontSize: 10, color: 'var(--ink-faint)' }}>left</div>
</div>
</div>
))
) : (
<EmptyState message="No alerts — all stocked." />
)}
</div>
</Card>
</div>
{/* Pending orders */}
<div style={{ gridColumn: 'span 6' }}>
<Card>
<CardHeader
title="Pending orders"
subtitle={`${pendingPurchases?.data.length ?? 0} awaiting arrival`}
action={
<Button variant="ghost" size="sm">
All purchases
</Button>
}
/>
<div
style={{
padding: '4px 18px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
{pendingPurchases?.data.length ? (
pendingPurchases.data.map((p) => (
<div
key={p._id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 0',
borderBottom: '1px dashed var(--border)',
}}
>
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'var(--brand-soft)',
color: 'var(--brand-soft-ink)',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
}}
>
<Icon name="truck" size={14} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: 13 }}>
{p.storeName ?? 'Unknown store'}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>
{p.items.length} item{p.items.length > 1 ? 's' : ''}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<Pill tone={p.status === 'ordered' ? 'warn' : 'ok'}>{p.status}</Pill>
</div>
</div>
))
) : (
<EmptyState message="No pending orders." />
)}
</div>
</Card>
</div>
{/* Recent activity */}
<div style={{ gridColumn: 'span 6' }}>
<Card>
<CardHeader
title="Recent activity"
subtitle="Cabinet changes"
action={
<Button variant="ghost" size="sm">
See all
</Button>
}
/>
<div style={{ padding: '4px 18px 16px' }}>
{recentEvents?.data.length ? (
recentEvents.data.slice(0, 5).map((event) => (
<div
key={event._id}
style={{
display: 'flex',
gap: 10,
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px dashed var(--border)',
}}
>
<Pill
tone={
event.eventType === 'consumed'
? 'info'
: event.eventType === 'added'
? 'ok'
: 'warn'
}
style={{ minWidth: 76, justifyContent: 'center' }}
>
{event.eventType}
</Pill>
<span style={{ flex: 1, fontSize: 12 }}>
<strong style={{ fontWeight: 500 }}>{event.medicineName}</strong>
</span>
<span
className="mono"
style={{ fontSize: 10, color: 'var(--ink-faint)', flexShrink: 0 }}
>
{new Date(event.createdAt).toLocaleDateString()}
</span>
</div>
))
) : (
<EmptyState message="No recent activity." />
)}
</div>
</Card>
</div>
</div>
</div>
</>
);
}
function StatBadge({
label,
value,
tone,
}: {
label: string;
value: number;
tone?: 'warn' | 'danger';
}) {
const color =
tone === 'danger' ? 'var(--danger)' : tone === 'warn' ? 'var(--warn)' : 'var(--brand)';
return (
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
padding: '10px 14px',
textAlign: 'center',
}}
>
<div
className="num"
style={{ fontSize: 24, fontWeight: 600, color, letterSpacing: '-0.02em', lineHeight: 1.15 }}
>
{value}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 2 }}>{label}</div>
</div>
);
}
function DashboardCard({
title,
description,
href,
}: {
title: string;
description: string;
href: string;
}) {
function EmptyState({ message }: { message: string }) {
return (
<Link
href={href}
className="rounded-xl border bg-white p-6 shadow-sm hover:shadow-md transition-shadow"
<div
style={{ padding: '12px 0', fontSize: 13, color: 'var(--ink-muted)', textAlign: 'center' }}
>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-1 text-sm text-gray-500">{description}</p>
</Link>
{message}
</div>
);
}
function DashboardSkeleton() {
return (
<div style={{ padding: '28px 32px', maxWidth: 1400 }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
height: 80,
borderRadius: 'var(--r-md)',
background: 'var(--bg-inset)',
marginBottom: 16,
animation: 'pulse 1.5s infinite',
}}
/>
))}
</div>
);
}

View file

@ -1,14 +1,31 @@
import { Sidebar } from '@/components/layout/Sidebar';
import { TopBar } from '@/components/layout/TopBar';
import { PageHeaderProvider } from '@/components/layout/PageHeaderContext';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<Sidebar />
<div className="flex flex-1 flex-col">
<TopBar />
<main className="flex-1 overflow-auto p-6">{children}</main>
<PageHeaderProvider>
<div
style={{
display: 'grid',
gridTemplateColumns: '248px 1fr',
minHeight: '100vh',
background: 'var(--bg)',
}}
>
<Sidebar />
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<TopBar />
<main
style={{
flex: 1,
overflowY: 'auto',
}}
>
{children}
</main>
</div>
</div>
</div>
</PageHeaderProvider>
);
}

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
@ -28,7 +29,9 @@ vi.mock('@/services/medicines', () => ({
listMedicineProducts: mockListMedicineProducts,
}));
vi.mock('@/services/stores', () => ({ listStores: mockListStores }));
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => props.children,
}));
import MedicinePricesPage from '../page';
@ -74,7 +77,9 @@ describe('MedicinePricesPage', () => {
it('loads price history when medicine selected', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
data: [
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
],
pagination: { cursor: null, hasMore: false },
});
mockGetPriceHistory.mockResolvedValue({
@ -102,7 +107,9 @@ describe('MedicinePricesPage', () => {
await waitFor(() => screen.getByText('Select a medicine'));
fireEvent.change(screen.getAllByRole('combobox')[0]!, { target: { value: 'med-1' } });
await waitFor(() => expect(mockGetPriceHistory).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)));
await waitFor(() =>
expect(mockGetPriceHistory).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)),
);
// Wait for price records to render
await waitFor(() => expect(screen.getByText('Walgreens')).toBeInTheDocument());
});
@ -116,13 +123,15 @@ describe('MedicinePricesPage', () => {
await waitFor(() => screen.getByRole('button', { name: 'Record Price', hidden: false }));
// The submit button inside the form also has text 'Record Price'
const submitBtn = screen.getAllByRole('button', { name: 'Record Price' }).find(
(b) => b.getAttribute('type') === 'submit',
);
const submitBtn = screen
.getAllByRole('button', { name: 'Record Price' })
.find((b) => b.getAttribute('type') === 'submit');
if (submitBtn) {
fireEvent.submit(submitBtn.closest('form')!);
await waitFor(() =>
expect(screen.getByText('Please select a medicine, a product, and a store.')).toBeInTheDocument(),
expect(
screen.getByText('Please select a medicine, a product, and a store.'),
).toBeInTheDocument(),
);
}
});
@ -130,7 +139,9 @@ describe('MedicinePricesPage', () => {
it('shows error when price history fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
data: [
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
],
pagination: { cursor: null, hasMore: false },
});
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
@ -146,11 +157,21 @@ describe('MedicinePricesPage', () => {
it('records a price successfully', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
data: [
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
],
pagination: { cursor: null, hasMore: false },
});
mockListStores.mockResolvedValue({
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
mockListMedicineProducts.mockResolvedValue({
@ -176,17 +197,28 @@ describe('MedicinePricesPage', () => {
fireEvent.change(screen.getByDisplayValue('Select product'), { target: { value: 'prod-1' } });
// Submit form
fireEvent.submit(screen.getByRole('button', { name: 'Record Price', hidden: true }).closest('form')!);
fireEvent.submit(
screen.getByRole('button', { name: 'Record Price', hidden: true }).closest('form')!,
);
await waitFor(() => expect(mockRecordPrice).toHaveBeenCalledWith('hh1', expect.objectContaining({ medicineId: 'med-1' })));
await waitFor(() =>
expect(mockRecordPrice).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ medicineId: 'med-1' }),
),
);
// Form should close after success
await waitFor(() => expect(screen.queryByText('Record Price', { selector: 'h2' })).not.toBeInTheDocument());
await waitFor(() =>
expect(screen.queryByText('Record Price', { selector: 'h2' })).not.toBeInTheDocument(),
);
});
it('shows Load more button in price history', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
data: [
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
],
pagination: { cursor: null, hasMore: false },
});
mockGetPriceHistory.mockResolvedValue({
@ -223,11 +255,21 @@ describe('MedicinePricesPage', () => {
it('shows store filter when medicine selected and changes it', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
data: [
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
],
pagination: { cursor: null, hasMore: false },
});
mockListStores.mockResolvedValue({
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
@ -245,7 +287,9 @@ describe('MedicinePricesPage', () => {
it('dismisses price history error', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
data: [
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
],
pagination: { cursor: null, hasMore: false },
});
mockGetPriceHistory.mockRejectedValue(new Error('History load failed'));
@ -263,13 +307,31 @@ describe('MedicinePricesPage', () => {
it('shows store comparison table', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
data: [
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
],
pagination: { cursor: null, hasMore: false },
});
mockCompareStores.mockResolvedValue({
data: [
{ storeId: 'st-1', storeName: 'Walgreens', latestPrice: 12.99, latestPricePerUnit: 0.14, currency: 'USD', date: '2026-01-01T00:00:00.000Z', isInsurancePrice: false },
{ storeId: 'st-2', storeName: 'CVS', latestPrice: 14.99, latestPricePerUnit: 0.17, currency: 'USD', date: '2026-01-01T00:00:00.000Z', isInsurancePrice: false },
{
storeId: 'st-1',
storeName: 'Walgreens',
latestPrice: 12.99,
latestPricePerUnit: 0.14,
currency: 'USD',
date: '2026-01-01T00:00:00.000Z',
isInsurancePrice: false,
},
{
storeId: 'st-2',
storeName: 'CVS',
latestPrice: 14.99,
latestPricePerUnit: 0.17,
currency: 'USD',
date: '2026-01-01T00:00:00.000Z',
isInsurancePrice: false,
},
],
});
@ -308,7 +370,9 @@ describe('MedicinePricesPage', () => {
await userEvent.click(screen.getByText('Record Price'));
await waitFor(() => screen.getByPlaceholderText('Search medicines...'));
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), { target: { value: 'Met' } });
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), {
target: { value: 'Met' },
});
// Notes field (no placeholder, but maxLength 1000)
const notesInput = document.querySelector('input[maxLength="1000"]') as HTMLElement;

View file

@ -3,11 +3,8 @@
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import {
recordPrice,
getPriceHistory,
compareStores,
} from '@/services/medicine-prices';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { recordPrice, getPriceHistory, compareStores } from '@/services/medicine-prices';
import { listMedicines, listMedicineProducts } from '@/services/medicines';
import { listStores } from '@/services/stores';
import { DosageUnit } from '@meshitrack/shared';
@ -120,22 +117,18 @@ function RecordPriceForm({
}
return (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card mb-6">
<h2 className="text-lg font-semibold mb-4">Record Price</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Store</label>
<label className="mt-field-label">Store</label>
<select
value={storeId}
onChange={(e) => setStoreId(e.target.value)}
required
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
<option value="">Select store</option>
{stores.map((s) => (
@ -147,7 +140,7 @@ function RecordPriceForm({
{stores.length === 0 && (
<p className="text-xs text-gray-400 mt-1">
No stores yet.{' '}
<Link href="/stores" className="text-primary-600 underline">
<Link href="/stores" className="mt-link">
Add a store first
</Link>
</p>
@ -155,19 +148,19 @@ function RecordPriceForm({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Medicine</label>
<label className="mt-field-label">Medicine</label>
<input
type="text"
value={medicineSearch}
onChange={(e) => setMedicineSearch(e.target.value)}
placeholder="Search medicines..."
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none mb-2"
className="mt-field mb-2"
/>
<select
value={medicineId}
onChange={(e) => handleMedicineChange(e.target.value)}
required
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
<option value="">Select medicine</option>
{filteredMedicines.map((m) => (
@ -179,7 +172,7 @@ function RecordPriceForm({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Product</label>
<label className="mt-field-label">Product</label>
{productsLoading ? (
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
) : (
@ -188,9 +181,11 @@ function RecordPriceForm({
onChange={(e) => handleProductChange(e.target.value)}
required
disabled={!medicineId}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
className="mt-field"
>
<option value="">{medicineId ? 'Select product' : 'Select a medicine first'}</option>
<option value="">
{medicineId ? 'Select product' : 'Select a medicine first'}
</option>
{products.map((p) => (
<option key={p._id} value={p._id}>
{p.brand ?? 'Generic'} {p.packageSize} {p.packageUnit}
@ -201,7 +196,7 @@ function RecordPriceForm({
{medicineId && !productsLoading && products.length === 0 && (
<p className="text-xs text-gray-400 mt-1">
No products for this medicine.{' '}
<Link href={`/medicines/${medicineId}`} className="text-primary-600 underline">
<Link href={`/medicines/${medicineId}`} className="mt-link">
Add a product first
</Link>
</p>
@ -210,7 +205,7 @@ function RecordPriceForm({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Price</label>
<label className="mt-field-label">Price</label>
<input
type="number"
required
@ -219,11 +214,11 @@ function RecordPriceForm({
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="9.99"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Currency</label>
<label className="mt-field-label">Currency</label>
<input
type="text"
required
@ -231,14 +226,14 @@ function RecordPriceForm({
value={currency}
onChange={(e) => setCurrency(e.target.value)}
placeholder="USD"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Package size</label>
<label className="mt-field-label">Package size</label>
<input
type="number"
required
@ -247,15 +242,15 @@ function RecordPriceForm({
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
placeholder="90"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
<label className="mt-field-label">Unit</label>
<select
value={unit}
onChange={(e) => setUnit(e.target.value as DosageUnit)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
{Object.values(DosageUnit).map((u) => (
<option key={u} value={u}>
@ -267,15 +262,13 @@ function RecordPriceForm({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes (optional)
</label>
<label className="mt-field-label">Notes (optional)</label>
<input
type="text"
maxLength={1000}
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
@ -285,7 +278,7 @@ function RecordPriceForm({
id="isInsurancePrice"
checked={isInsurancePrice}
onChange={(e) => setIsInsurancePrice(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="isInsurancePrice" className="text-sm font-medium text-gray-700">
Insurance price
@ -294,18 +287,10 @@ function RecordPriceForm({
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
{submitting ? 'Recording...' : 'Record Price'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
Cancel
</button>
</div>
@ -371,14 +356,15 @@ function PriceHistory({
}, [householdId, selectedMedicineId, selectedStoreId]);
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card">
<h2 className="text-lg font-semibold mb-4">Price History</h2>
<div className="mb-4 flex flex-wrap items-center gap-3">
<select
value={selectedMedicineId}
onChange={(e) => setSelectedMedicineId(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">Select a medicine</option>
{medicines.map((m) => (
@ -391,7 +377,8 @@ function PriceHistory({
<select
value={selectedStoreId}
onChange={(e) => setSelectedStoreId(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All stores</option>
{stores.map((s) => (
@ -404,7 +391,7 @@ function PriceHistory({
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
@ -439,18 +426,15 @@ function PriceHistory({
</thead>
<tbody className="divide-y divide-gray-100">
{comparison.map((item, i) => (
<tr key={item.storeId} className={i === 0 ? 'text-green-700 font-medium' : ''}>
<tr
key={item.storeId}
className={i === 0 ? 'text-green-700 font-medium' : ''}
>
<td className="py-2">
{item.storeName}
{i === 0 && (
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs">
cheapest
</span>
)}
{i === 0 && <span className="ml-2 mt-pill mt-pill--ok">cheapest</span>}
{item.isInsurancePrice && (
<span className="ml-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs">
insurance
</span>
<span className="ml-1 mt-pill mt-pill--info">insurance</span>
)}
</td>
<td className="py-2 text-right">
@ -490,9 +474,7 @@ function PriceHistory({
<td className="py-2">
{r.storeName}
{r.isInsurancePrice && (
<span className="ml-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs">
ins
</span>
<span className="ml-1 mt-pill mt-pill--info">ins</span>
)}
{r.notes && (
<span className="ml-1 text-xs text-gray-400"> {r.notes}</span>
@ -540,18 +522,18 @@ function MedicinePricesContent({ householdId }: { householdId: string }) {
const [historyKey, setHistoryKey] = useState(0);
useEffect(() => {
listMedicines(householdId, { limit: 100 }).then((r) => setMedicines(r.data)).catch(() => {});
listStores(householdId, { limit: 100 }).then((r) => setStores(r.data)).catch(() => {});
listMedicines(householdId, { limit: 100 })
.then((r) => setMedicines(r.data))
.catch(() => {});
listStores(householdId, { limit: 100 })
.then((r) => setStores(r.data))
.catch(() => {});
}, [householdId]);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Medicine Prices</h1>
<button
onClick={() => setShowForm(!showForm)}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
<button onClick={() => setShowForm(!showForm)} className="mt-btn mt-btn--primary">
{showForm ? 'Cancel' : 'Record Price'}
</button>
</div>
@ -586,33 +568,54 @@ export default function MedicinePricesPage() {
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicine Prices</h1>
<div className="animate-pulse space-y-4">
<div className="h-10 w-48 rounded-lg bg-gray-200" />
<div className="h-40 rounded-xl bg-gray-200" />
<div className="h-40 rounded-xl bg-gray-200" />
<>
<SetPageHeader title="Medicine Prices" subtitle="Track & compare across stores" />
<div style={{ padding: '28px 32px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{ height: 64, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
/>
))}
</div>
</div>
</div>
</>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicine Prices</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before tracking prices.
</p>
<>
<SetPageHeader title="Medicine Prices" subtitle="Track & compare across stores" />
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to{' '}
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
create or join a household
</Link>{' '}
before tracking prices.
</p>
</div>
</div>
</div>
</>
);
}
return <MedicinePricesContent householdId={householdId} />;
return (
<>
<SetPageHeader title="Medicine Prices" subtitle="Track & compare across stores" />
<div className="mt-page">
<MedicinePricesContent householdId={householdId} />
</div>
</>
);
}

View file

@ -5,10 +5,7 @@ import { listCabinetEvents, getSpendingSummary } from '@/services/cabinet-events
import { listMedicines } from '@/services/medicines';
import { CabinetEventType } from '@meshitrack/shared';
import type { z } from 'zod/v4';
import type {
CabinetEventResponseSchema,
SpendingSummaryResponseSchema,
} from '@meshitrack/shared';
import type { CabinetEventResponseSchema, SpendingSummaryResponseSchema } from '@meshitrack/shared';
type CabinetEvent = z.infer<typeof CabinetEventResponseSchema>;
type SpendingSummary = z.infer<typeof SpendingSummaryResponseSchema>;
@ -27,13 +24,13 @@ const EVENT_TYPE_LABELS: Record<string, string> = {
deleted: 'Deleted',
};
const EVENT_TYPE_COLORS: Record<string, string> = {
purchased: 'bg-green-100 text-green-700',
consumed: 'bg-blue-100 text-blue-700',
adjusted: 'bg-yellow-100 text-yellow-700',
discarded: 'bg-red-100 text-red-700',
restored: 'bg-purple-100 text-purple-700',
deleted: 'bg-gray-100 text-gray-600',
const EVENT_TYPE_PILL: Record<string, string> = {
purchased: 'mt-pill--ok',
consumed: 'mt-pill--info',
adjusted: 'mt-pill--warn',
discarded: 'mt-pill--danger',
restored: 'mt-pill--brand',
deleted: 'mt-pill--ghost',
};
function formatDateTime(dateStr: string): string {
@ -41,7 +38,7 @@ function formatDateTime(dateStr: string): string {
}
/* v8 ignore next 4 */
function formatQuantityChange(event: CabinetEvent): string {
function _formatQuantityChange(event: CabinetEvent): string {
const sign = event.quantity > 0 ? '+' : '';
return `${sign}${event.quantity}`;
}
@ -49,9 +46,7 @@ function formatQuantityChange(event: CabinetEvent): string {
function QuantityBadge({ quantity }: { quantity: number }) {
const isPositive = quantity > 0;
return (
<span
className={`text-sm font-semibold ${isPositive ? 'text-green-600' : 'text-red-600'}`}
>
<span className={`text-sm font-semibold ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
{isPositive ? '+' : ''}
{quantity}
</span>
@ -96,14 +91,15 @@ function SpendingSummaryView({
const PERIOD_LABELS = { month: 'This month', quarter: 'This quarter', year: 'This year' };
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<h2 className="text-lg font-semibold">Spending Summary</h2>
<div className="flex items-center gap-2">
<select
value={period}
onChange={(e) => setPeriod(e.target.value as 'month' | 'quarter' | 'year')}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
{Object.entries(PERIOD_LABELS).map(([v, label]) => (
<option key={v} value={v}>
@ -114,7 +110,8 @@ function SpendingSummaryView({
<select
value={medicineId}
onChange={(e) => setMedicineId(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All medicines</option>
{medicines.map((m) => (
@ -126,11 +123,7 @@ function SpendingSummaryView({
</div>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
{loading ? (
<div className="animate-pulse space-y-2">
@ -155,9 +148,7 @@ function SpendingSummaryView({
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<span className="text-sm font-medium text-gray-900">
{item.medicineName}
</span>
<span className="text-sm font-medium text-gray-900">{item.medicineName}</span>
<span className="ml-2 text-xs text-gray-500">
{item.purchaseCount} purchase{item.purchaseCount !== 1 ? 's' : ''} &bull;{' '}
avg {summary.currency ? `${summary.currency} ` : ''}
@ -256,14 +247,15 @@ function EventTimeline({
}, [householdId, filterEventType, filterMedicineId, startDate, endDate]);
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card">
<h2 className="text-lg font-semibold mb-4">Cabinet Activity</h2>
<div className="mb-4 flex flex-wrap items-center gap-3">
<select
value={filterEventType}
onChange={(e) => setFilterEventType(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All event types</option>
{Object.values(CabinetEventType).map((t) => (
@ -275,7 +267,8 @@ function EventTimeline({
<select
value={filterMedicineId}
onChange={(e) => setFilterMedicineId(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All medicines</option>
{medicines.map((m) => (
@ -288,14 +281,16 @@ function EventTimeline({
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
title="Start date"
/>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
title="End date"
/>
{(filterEventType || filterMedicineId || startDate || endDate) && (
@ -306,7 +301,7 @@ function EventTimeline({
setStartDate('');
setEndDate('');
}}
className="text-sm text-gray-500 underline"
className="mt-link text-sm"
>
Clear filters
</button>
@ -314,7 +309,7 @@ function EventTimeline({
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
@ -349,7 +344,7 @@ function EventTimeline({
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${/* v8 ignore next */ EVENT_TYPE_COLORS[event.eventType] ?? 'bg-gray-100 text-gray-600'}`}
className={`mt-pill ${/* v8 ignore next */ EVENT_TYPE_PILL[event.eventType] ?? 'mt-pill--ghost'}`}
>
{/* v8 ignore next */ EVENT_TYPE_LABELS[event.eventType] ?? event.eventType}
</span>
@ -385,10 +380,7 @@ function EventTimeline({
{hasMore && (
<div className="mt-4 text-center">
<button
onClick={() => fetchEvents(true)}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<button onClick={() => fetchEvents(true)} className="mt-btn mt-btn--ghost">
Load more
</button>
</div>
@ -407,7 +399,9 @@ export function ActivityTab({ householdId }: { householdId: string }) {
useEffect(() => {
listMedicines(householdId, { limit: 100 })
.then((r) =>
setMedicines(r.data.map((m: { _id: string; name: string }) => ({ _id: m._id, name: m.name }))),
setMedicines(
r.data.map((m: { _id: string; name: string }) => ({ _id: m._id, name: m.name })),
),
)
.catch(() => {});
}, [householdId]);

File diff suppressed because it is too large Load diff

View file

@ -29,11 +29,11 @@ const CATEGORY_LABELS: Record<string, string> = {
other: 'Other',
};
const CATEGORY_COLORS: Record<string, string> = {
prescription: 'bg-blue-100 text-blue-700',
otc: 'bg-green-100 text-green-700',
supplement: 'bg-purple-100 text-purple-700',
other: 'bg-gray-100 text-gray-700',
const CATEGORY_PILL: Record<string, string> = {
prescription: 'mt-pill--info',
otc: 'mt-pill--ok',
supplement: 'mt-pill--brand',
other: 'mt-pill--ghost',
};
export function LibraryTab({ householdId }: { householdId: string }) {
@ -83,16 +83,13 @@ export function LibraryTab({ householdId }: { householdId: string }) {
<div>
<div className="flex items-center justify-between mb-4">
<div />
<button
onClick={() => setShowForm(!showForm)}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
<button onClick={() => setShowForm(!showForm)} className="mt-btn mt-btn--primary">
{showForm ? 'Cancel' : 'Add Medicine'}
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
@ -117,12 +114,13 @@ export function LibraryTab({ householdId }: { householdId: string }) {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search medicines..."
className="w-full max-w-md rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field max-w-md"
/>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All Categories</option>
{Object.values(MedicineCategory).map((c) => (
@ -134,7 +132,8 @@ export function LibraryTab({ householdId }: { householdId: string }) {
<select
value={filterForm}
onChange={(e) => setFilterForm(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All Forms</option>
{Object.values(MedicineForm).map((f) => (
@ -176,13 +175,13 @@ export function LibraryTab({ householdId }: { householdId: string }) {
</Link>
<div className="flex items-center gap-3 ml-4">
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${CATEGORY_COLORS[med.category] ?? CATEGORY_COLORS['other']}`}
className={`mt-pill ${CATEGORY_PILL[med.category] ?? CATEGORY_PILL['other']}`}
>
{CATEGORY_LABELS[med.category] ?? med.category}
</span>
<button
onClick={() => handleDelete(med._id, med.name)}
className="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
className="mt-btn mt-btn--danger-icon"
title="Delete"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -242,17 +241,13 @@ function CreateMedicineForm({
}
return (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card mb-6">
<h2 className="text-lg font-semibold mb-4">Add Medicine</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<label className="mt-field-label">Name</label>
<input
type="text"
required
@ -260,15 +255,15 @@ function CreateMedicineForm({
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. Metformin"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Form</label>
<label className="mt-field-label">Form</label>
<select
value={formData.form}
onChange={(e) => setFormData({ ...formData, form: e.target.value as MedicineForm })}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
{Object.values(MedicineForm).map((f) => (
<option key={f} value={f}>
@ -279,7 +274,7 @@ function CreateMedicineForm({
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Strength</label>
<label className="mt-field-label">Strength</label>
<input
type="number"
required
@ -288,17 +283,17 @@ function CreateMedicineForm({
value={formData.strength || ''}
onChange={(e) => setFormData({ ...formData, strength: Number(e.target.value) })}
placeholder="500"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
<label className="mt-field-label">Unit</label>
<select
value={formData.strengthUnit}
onChange={(e) =>
setFormData({ ...formData, strengthUnit: e.target.value as StrengthUnit })
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
{Object.values(StrengthUnit).map((u) => (
<option key={u} value={u}>
@ -309,13 +304,13 @@ function CreateMedicineForm({
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<label className="mt-field-label">Category</label>
<select
value={formData.category}
onChange={(e) =>
setFormData({ ...formData, category: e.target.value as MedicineCategory })
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
{Object.values(MedicineCategory).map((c) => (
<option key={c} value={c}>
@ -325,30 +320,22 @@ function CreateMedicineForm({
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes (optional)</label>
<label className="mt-field-label">Notes (optional)</label>
<input
type="text"
maxLength={1000}
value={formData.notes ?? ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value || undefined })}
placeholder="Any additional notes"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
{submitting ? 'Creating...' : 'Create Medicine'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
Cancel
</button>
</div>

View file

@ -1,12 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
listFills,
previewFill,
executeFill,
undoFill,
} from '@/services/organizer';
import { listFills, previewFill, executeFill, undoFill } from '@/services/organizer';
import { listRegimens } from '@/services/regimens';
import { OrganizerFillStatus } from '@meshitrack/shared';
import type { z } from 'zod/v4';
@ -26,12 +21,6 @@ const STATUS_LABELS: Record<string, string> = {
reversed: 'Reversed',
};
const STATUS_COLORS: Record<string, string> = {
completed: 'bg-green-100 text-green-700',
partial: 'bg-yellow-100 text-yellow-700',
reversed: 'bg-gray-100 text-gray-500',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
@ -54,20 +43,16 @@ function PreviewResult({
onTogglePartial: (v: boolean) => void;
}) {
return (
<div className="rounded-xl border bg-white p-6 shadow-sm space-y-5">
<div className="mt-card">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">
Preview: {preview.regimenName} &mdash; {preview.numberOfDays} day
{preview.numberOfDays !== 1 ? 's' : ''}
</h3>
{preview.hasShortages ? (
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-medium text-yellow-700">
Shortages detected
</span>
<span className="mt-pill mt-pill--warn">Shortages detected</span>
) : (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-700">
Ready to fill
</span>
<span className="mt-pill mt-pill--ok">Ready to fill</span>
)}
</div>
@ -87,9 +72,7 @@ function PreviewResult({
Available: <strong>{item.quantityAvailable}</strong>
</span>
{item.isShort && (
<span className="text-yellow-700 font-semibold">
Short: {item.shortage}
</span>
<span className="text-yellow-700 font-semibold">Short: {item.shortage}</span>
)}
</div>
</div>
@ -98,7 +81,9 @@ function PreviewResult({
{item.cabinetBreakdown.map((b, i) => (
<span key={i} className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
{b.quantityToTake} units
{b.expirationDate ? ` (exp ${new Date(b.expirationDate).toLocaleDateString()})` : ''}
{b.expirationDate
? ` (exp ${new Date(b.expirationDate).toLocaleDateString()})`
: ''}
</span>
))}
</div>
@ -114,7 +99,7 @@ function PreviewResult({
id="allowPartial"
checked={allowPartial}
onChange={(e) => onTogglePartial(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="allowPartial" className="text-sm text-gray-700">
Allow partial fill (fill what is available)
@ -126,14 +111,11 @@ function PreviewResult({
<button
onClick={onConfirm}
disabled={submitting || (preview.hasShortages && !allowPartial)}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
className="mt-btn mt-btn--primary"
>
{submitting ? 'Filling...' : 'Confirm fill'}
</button>
<button
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<button onClick={onCancel} className="mt-btn mt-btn--ghost">
Back
</button>
</div>
@ -182,7 +164,12 @@ function FillWizard({
setError('');
setFilling(true);
try {
await executeFill(householdId, { regimenId, numberOfDays, allowPartial, notes: notes || undefined });
await executeFill(householdId, {
regimenId,
numberOfDays,
allowPartial,
notes: notes || undefined,
});
setPreview(null);
setRegimenId('');
setNumberOfDays(7);
@ -218,13 +205,9 @@ function FillWizard({
}
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card">
<h2 className="text-lg font-semibold mb-4">Fill Pill Organizer</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
{activeRegimens.length === 0 ? (
<p className="text-sm text-gray-500">
No active regimens found. Create and activate a regimen before filling.
@ -233,23 +216,24 @@ function FillWizard({
<form onSubmit={handlePreview} className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Regimen</label>
<label className="mt-field-label">Regimen</label>
<select
required
value={regimenId}
onChange={(e) => setRegimenId(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
<option value="">Select regimen...</option>
{activeRegimens.map((r) => (
<option key={r._id} value={r._id}>
{r.name} ({r.medications.length} medication{r.medications.length !== 1 ? 's' : ''})
{r.name} ({r.medications.length} medication
{r.medications.length !== 1 ? 's' : ''})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Number of days</label>
<label className="mt-field-label">Number of days</label>
<input
type="number"
required
@ -257,28 +241,22 @@ function FillWizard({
max={90}
value={numberOfDays}
onChange={(e) => setNumberOfDays(Number(e.target.value))}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes (optional)
</label>
<label className="mt-field-label">Notes (optional)</label>
<input
type="text"
maxLength={1000}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any notes for this fill"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
</div>
<button
type="submit"
disabled={previewing}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
<button type="submit" disabled={previewing} className="mt-btn mt-btn--primary">
{previewing ? 'Calculating...' : 'Preview fill'}
</button>
</form>
@ -289,13 +267,7 @@ function FillWizard({
// --- Fill history list ---
function FillHistory({
householdId,
refreshKey,
}: {
householdId: string;
refreshKey: number;
}) {
function FillHistory({ householdId, refreshKey }: { householdId: string; refreshKey: number }) {
const [fills, setFills] = useState<OrganizerFill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@ -331,13 +303,14 @@ function FillHistory({
}
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Fill History</h2>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All statuses</option>
{Object.values(OrganizerFillStatus).map((s) => (
@ -349,7 +322,7 @@ function FillHistory({
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
@ -374,7 +347,7 @@ function FillHistory({
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{fill.regimenName}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[fill.status] ?? STATUS_COLORS['completed']}`}
className={`mt-pill ${fill.status === 'completed' ? 'mt-pill--ok' : fill.status === 'partial' ? 'mt-pill--warn' : 'mt-pill--ghost'}`}
>
{STATUS_LABELS[fill.status] ?? fill.status}
</span>
@ -384,18 +357,12 @@ function FillHistory({
{fill.items.length} medicine{fill.items.length !== 1 ? 's' : ''} &bull;{' '}
{formatDate(fill.fillDate)}
</p>
{fill.notes && (
<p className="text-xs text-gray-400 mt-1">{fill.notes}</p>
)}
{fill.notes && <p className="text-xs text-gray-400 mt-1">{fill.notes}</p>}
<div className="flex flex-wrap gap-1 mt-2">
{fill.items.map((item, i) => (
<span
key={i}
className={`rounded-full px-2 py-0.5 text-xs ${
item.wasShort
? 'bg-yellow-50 text-yellow-700'
: 'bg-blue-50 text-blue-700'
}`}
className={`mt-pill ${item.wasShort ? 'mt-pill--warn' : 'mt-pill--info'}`}
>
{item.medicineName}: {item.quantityTaken}/{item.quantityNeeded}
{item.wasShort ? ' (short)' : ''}
@ -406,7 +373,7 @@ function FillHistory({
{fill.status !== OrganizerFillStatus.REVERSED && (
<button
onClick={() => handleUndo(fill._id)}
className="shrink-0 rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition-colors"
className="mt-btn mt-btn--danger-ghost"
>
Undo
</button>
@ -443,11 +410,7 @@ export function OrganizerTab({ householdId }: { householdId: string }) {
{regimensLoading ? (
<div className="animate-pulse rounded-xl border bg-white p-6 h-40" />
) : (
<FillWizard
householdId={householdId}
regimens={regimens}
onFilled={handleFilled}
/>
<FillWizard householdId={householdId} regimens={regimens} onFilled={handleFilled} />
)}
<FillHistory householdId={householdId} refreshKey={fillRefreshKey} />
</div>

View file

@ -13,7 +13,7 @@ import {
DosageFrequency,
TimeOfDay,
DosageUnit,
MedicineForm,
type MedicineForm,
allowedUnitsForForm,
defaultUnitForForm,
} from '@meshitrack/shared';
@ -99,23 +99,28 @@ function MedicationRow({
<button
type="button"
onClick={() => onRemove(index)}
className="rounded p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
className="mt-btn mt-btn--danger-icon"
title="Remove medication"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Medicine</label>
<label className="mt-field-label">Medicine</label>
<select
required
value={medication.medicineId}
onChange={(e) => handleMedicineChange(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
<option value="">Select medicine...</option>
{medicines.map((m) => (
@ -128,7 +133,7 @@ function MedicationRow({
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">Dosage</label>
<label className="mt-field-label">Dosage</label>
<input
type="number"
required
@ -136,17 +141,17 @@ function MedicationRow({
step="any"
value={medication.dosage || ''}
onChange={(e) => onChange(index, { ...medication, dosage: Number(e.target.value) })}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">Unit</label>
<label className="mt-field-label">Unit</label>
<select
value={medication.dosageUnit}
onChange={(e) =>
onChange(index, { ...medication, dosageUnit: e.target.value as DosageUnit })
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
{allowedUnits.map((u) => (
<option key={u} value={u}>
@ -158,7 +163,7 @@ function MedicationRow({
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Frequency</label>
<label className="mt-field-label">Frequency</label>
<select
value={medication.frequency}
onChange={(e) =>
@ -171,7 +176,7 @@ function MedicationRow({
: undefined,
})
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
{Object.values(DosageFrequency).map((f) => (
<option key={f} value={f}>
@ -183,7 +188,7 @@ function MedicationRow({
{medication.frequency === DosageFrequency.CUSTOM && (
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Times per day</label>
<label className="mt-field-label">Times per day</label>
<input
type="number"
required
@ -193,15 +198,13 @@ function MedicationRow({
onChange={(e) =>
onChange(index, { ...medication, customFrequencyPerDay: Number(e.target.value) })
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Time of day (optional)
</label>
<label className="mt-field-label">Time of day (optional)</label>
<select
value={medication.timeOfDay ?? ''}
onChange={(e) =>
@ -210,7 +213,7 @@ function MedicationRow({
timeOfDay: e.target.value ? (e.target.value as TimeOfDay) : undefined,
})
}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
<option value="">Any time</option>
{Object.values(TimeOfDay).map((t) => (
@ -222,9 +225,7 @@ function MedicationRow({
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Instructions (optional)
</label>
<label className="mt-field-label">Instructions (optional)</label>
<input
type="text"
maxLength={500}
@ -233,7 +234,7 @@ function MedicationRow({
onChange(index, { ...medication, instructions: e.target.value || undefined })
}
placeholder="e.g. Take with food"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
</div>
@ -317,17 +318,13 @@ function RegimenForm({
}
return (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card mb-6">
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Regimen' : 'New Regimen'}</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<label className="mt-field-label">Name</label>
<input
type="text"
required
@ -335,7 +332,7 @@ function RegimenForm({
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Morning routine"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div className="flex items-center gap-3 pt-6">
@ -344,7 +341,7 @@ function RegimenForm({
id="isActive"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
Active
@ -355,11 +352,7 @@ function RegimenForm({
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-800">Medications</h3>
<button
type="button"
onClick={addMedication}
className="rounded-lg border border-primary-600 px-3 py-1.5 text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
>
<button type="button" onClick={addMedication} className="mt-btn mt-btn--ghost">
+ Add medication
</button>
</div>
@ -382,18 +375,10 @@ function RegimenForm({
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
{submitting ? 'Saving...' : initial ? 'Save changes' : 'Create regimen'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
Cancel
</button>
</div>
@ -549,16 +534,14 @@ export function RegimensTab({ householdId }: { householdId: string }) {
<select
value={filterActive}
onChange={(e) => setFilterActive(e.target.value as 'all' | 'active' | 'inactive')}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="all">All regimens</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<button
onClick={handleShowBurnRate}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<button onClick={handleShowBurnRate} className="mt-btn mt-btn--ghost">
{showBurnRate ? 'Hide burn rate' : 'Burn rate'}
</button>
</div>
@ -567,14 +550,14 @@ export function RegimensTab({ householdId }: { householdId: string }) {
setEditingRegimen(null);
setShowForm(!showForm);
}}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
className="mt-btn mt-btn--primary"
>
{showForm ? 'Cancel' : 'New Regimen'}
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
@ -583,7 +566,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
)}
{showBurnRate && (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<div className="mb-6 mt-card">
<h2 className="text-lg font-semibold mb-4">Burn Rate &amp; Spending Projections</h2>
{burnRateLoading ? (
<div className="animate-pulse space-y-2">
@ -630,7 +613,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
))}
</div>
) : regimens.length === 0 ? (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
<div className="mt-card text-center" style={{ color: 'var(--ink-muted)' }}>
{filterActive !== 'all'
? `No ${filterActive} regimens found.`
: isFormOpen
@ -640,17 +623,13 @@ export function RegimensTab({ householdId }: { householdId: string }) {
) : (
<div className="space-y-3">
{regimens.map((regimen) => (
<div key={regimen._id} className="rounded-xl border bg-white p-4 shadow-sm">
<div key={regimen._id} className="mt-card">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-gray-900">{regimen.name}</h3>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
regimen.isActive
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
className={`mt-pill ${regimen.isActive ? 'mt-pill--ok' : 'mt-pill--ghost'}`}
>
{regimen.isActive ? 'Active' : 'Inactive'}
</span>
@ -661,21 +640,20 @@ export function RegimensTab({ householdId }: { householdId: string }) {
</p>
<div className="flex flex-wrap gap-1">
{regimen.medications.map((med, i) => (
<span
key={i}
className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700"
>
<span key={i} className="mt-pill mt-pill--info">
{med.medicineName} {med.dosage} {med.dosageUnit} (
{FREQUENCY_LABELS[med.frequency] ?? med.frequency})
</span>
))}
</div>
<p className="mt-2 text-xs text-gray-400">Created {formatDate(regimen.createdAt)}</p>
<p className="mt-2 text-xs text-gray-400">
Created {formatDate(regimen.createdAt)}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleToggleActive(regimen)}
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-gray-50 transition-colors"
className="mt-btn mt-btn--ghost"
title={regimen.isActive ? 'Deactivate' : 'Activate'}
>
{regimen.isActive ? 'Deactivate' : 'Activate'}
@ -685,7 +663,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
setShowForm(false);
setEditingRegimen(regimen);
}}
className="rounded p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
className="mt-btn mt-btn--icon"
title="Edit"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -699,7 +677,7 @@ export function RegimensTab({ householdId }: { householdId: string }) {
</button>
<button
onClick={() => handleDelete(regimen._id, regimen.name)}
className="rounded p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
className="mt-btn mt-btn--danger-icon"
title="Delete"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import type React from 'react';
import userEvent from '@testing-library/user-event';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
@ -22,16 +22,21 @@ const {
mockUpdateMedicineProduct: vi.fn(),
}));
const { mockListCabinetItems, mockAdjustCabinetItemQuantity, mockDeleteCabinetItem } =
vi.hoisted(() => ({
const { mockListCabinetItems, mockAdjustCabinetItemQuantity, mockDeleteCabinetItem } = vi.hoisted(
() => ({
mockListCabinetItems: vi.fn(),
mockAdjustCabinetItemQuantity: vi.fn(),
mockDeleteCabinetItem: vi.fn(),
}));
}),
);
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('next/navigation', () => ({ useParams: mockUseParams }));
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => (
<a href={props.href}>{props.children}</a>
),
}));
vi.mock('@/services/medicines', () => ({
getMedicine: mockGetMedicine,
@ -109,9 +114,7 @@ describe('MedicineDetailPage', () => {
it('shows empty products state', async () => {
render(<MedicineDetailPage />);
await waitFor(() =>
expect(screen.getByText(/No products yet/)).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText(/No products yet/)).toBeInTheDocument());
});
it('toggles Add Product form', async () => {
@ -146,7 +149,9 @@ describe('MedicineDetailPage', () => {
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(mockUpdateMedicine).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)));
await waitFor(() =>
expect(mockUpdateMedicine).toHaveBeenCalledWith('hh1', 'med-1', expect.any(Object)),
);
});
it('deletes a product after confirmation', async () => {
@ -250,7 +255,9 @@ describe('MedicineDetailPage', () => {
await userEvent.click(screen.getAllByTitle('Edit')[1]!);
await waitFor(() => screen.getByDisplayValue('Glucophage'));
fireEvent.change(screen.getByDisplayValue('Glucophage'), { target: { value: 'Glucophage XR' } });
fireEvent.change(screen.getByDisplayValue('Glucophage'), {
target: { value: 'Glucophage XR' },
});
fireEvent.submit(screen.getByDisplayValue('Glucophage XR').closest('form')!);
await waitFor(() =>
@ -275,9 +282,9 @@ describe('MedicineDetailPage', () => {
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
// Change package unit to ml to show concentration fields
const unitSelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).value === 'vial',
) as HTMLSelectElement;
const unitSelect = screen
.getAllByRole('combobox')
.find((s) => (s as HTMLSelectElement).value === 'vial') as HTMLSelectElement;
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
await waitFor(() => expect(screen.getByPlaceholderText('e.g. 100')).toBeInTheDocument());
@ -337,9 +344,9 @@ describe('MedicineDetailPage', () => {
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '100' } });
// Change concentration unit
const concUnitSelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).options[0]?.text === '--',
) as HTMLSelectElement;
const concUnitSelect = screen
.getAllByRole('combobox')
.find((s) => (s as HTMLSelectElement).options[0]?.text === '--') as HTMLSelectElement;
if (concUnitSelect) {
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
}
@ -372,9 +379,9 @@ describe('MedicineDetailPage', () => {
fireEvent.change(screen.getByDisplayValue('60'), { target: { value: '90' } });
// Change package unit
const unitSelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).value === 'tablet',
) as HTMLSelectElement;
const unitSelect = screen
.getAllByRole('combobox')
.find((s) => (s as HTMLSelectElement).value === 'tablet') as HTMLSelectElement;
fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
expect(screen.getByDisplayValue('Glucophage')).toBeInTheDocument();
@ -398,13 +405,17 @@ describe('MedicineDetailPage', () => {
fireEvent.change(screen.getByPlaceholderText('e.g. Pfizer'), { target: { value: '' } });
// Change notes (truthy) then clear (falsy → undefined)
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: 'Store in fridge' } });
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: '' } });
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
target: { value: 'Store in fridge' },
});
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
target: { value: '' },
});
// Change unit to ml to show concentration fields
const unitSelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).value === 'vial',
) as HTMLSelectElement;
const unitSelect = screen
.getAllByRole('combobox')
.find((s) => (s as HTMLSelectElement).value === 'vial') as HTMLSelectElement;
fireEvent.change(unitSelect!, { target: { value: 'ml' } });
await waitFor(() => screen.getByPlaceholderText('e.g. 100'));
@ -413,9 +424,9 @@ describe('MedicineDetailPage', () => {
fireEvent.change(screen.getByPlaceholderText('e.g. 100'), { target: { value: '' } });
// Change concentration unit
const concUnitSelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).value === '',
) as HTMLSelectElement;
const concUnitSelect = screen
.getAllByRole('combobox')
.find((s) => (s as HTMLSelectElement).value === '') as HTMLSelectElement;
if (concUnitSelect) {
fireEvent.change(concUnitSelect, { target: { value: 'mg/ml' } });
}
@ -544,9 +555,9 @@ describe('MedicineDetailPage', () => {
if (nameInput) fireEvent.change(nameInput, { target: { value: 'Metformin XR' } });
// Change form select
const formSelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).options[0]?.value === 'tablet',
) as HTMLSelectElement;
const formSelect = screen
.getAllByRole('combobox')
.find((s) => (s as HTMLSelectElement).options[0]?.value === 'tablet') as HTMLSelectElement;
if (formSelect) fireEvent.change(formSelect, { target: { value: 'capsule' } });
// Change strength
@ -554,9 +565,11 @@ describe('MedicineDetailPage', () => {
if (strengthInput) fireEvent.change(strengthInput, { target: { value: '250' } });
// Change category select
const catSelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).options[0]?.value === 'prescription',
) as HTMLSelectElement;
const catSelect = screen
.getAllByRole('combobox')
.find(
(s) => (s as HTMLSelectElement).options[0]?.value === 'prescription',
) as HTMLSelectElement;
if (catSelect) fireEvent.change(catSelect, { target: { value: 'otc' } });
// Change notes (truthy value)
@ -607,9 +620,9 @@ describe('MedicineDetailPage', () => {
await waitFor(() => screen.getByText('Edit Medicine'));
// Change strength unit select (the one with 'mg' options)
const unitSelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).value === 'mg',
) as HTMLSelectElement;
const unitSelect = screen
.getAllByRole('combobox')
.find((s) => (s as HTMLSelectElement).value === 'mg') as HTMLSelectElement;
if (unitSelect) fireEvent.change(unitSelect, { target: { value: 'mcg' } });
expect(screen.getByText('Edit Medicine')).toBeInTheDocument();
@ -643,7 +656,9 @@ describe('MedicineDetailPage', () => {
await userEvent.click(screen.getByText('Add Product'));
await waitFor(() => screen.getByPlaceholderText('e.g. CVS Health'));
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), { target: { value: 'Brand X' } });
fireEvent.change(screen.getByPlaceholderText('e.g. CVS Health'), {
target: { value: 'Brand X' },
});
fireEvent.submit(screen.getByPlaceholderText('e.g. CVS Health').closest('form')!);
await waitFor(() => expect(screen.getByText('Failed to create product')).toBeInTheDocument());
@ -772,6 +787,8 @@ describe('MedicineDetailPage', () => {
await waitFor(() => screen.getAllByTitle('Delete'));
await userEvent.click(screen.getAllByTitle('Delete')[0]!);
await waitFor(() => expect(screen.getByText('Failed to delete cabinet item')).toBeInTheDocument());
await waitFor(() =>
expect(screen.getByText('Failed to delete cabinet item')).toBeInTheDocument(),
);
});
});

View file

@ -12,11 +12,7 @@ import {
updateMedicine,
updateMedicineProduct,
} from '@/services/medicines';
import {
listCabinetItems,
adjustCabinetItemQuantity,
deleteCabinetItem,
} from '@/services/cabinet';
import { listCabinetItems, adjustCabinetItemQuantity, deleteCabinetItem } from '@/services/cabinet';
import {
DosageUnit,
ConcentrationUnit,
@ -162,7 +158,9 @@ export default function MedicineDetailPage() {
function startEditProduct(product: MedicineProduct) {
setEditingProductId(product._id);
const validUnits = Object.values(DosageUnit) as string[];
const allowedUnits = allowedUnitsForForm((medicine?.form as MedicineForm) ?? MedicineForm.OTHER);
const allowedUnits = allowedUnitsForForm(
(medicine?.form as MedicineForm) ?? MedicineForm.OTHER,
);
const storedUnit = product.packageUnit;
const packageUnit = validUnits.includes(storedUnit)
? (storedUnit as DosageUnit)
@ -650,7 +648,8 @@ export default function MedicineDetailPage() {
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
>
{allowedUnitsForForm(
/* v8 ignore next */ (medicine?.form as MedicineForm) ?? MedicineForm.OTHER,
/* v8 ignore next */ (medicine?.form as MedicineForm) ??
MedicineForm.OTHER,
).map((u) => (
<option key={u} value={u}>
{u}

View file

@ -45,13 +45,17 @@ describe('ActivityTab', () => {
it('fetches spending summary on mount', async () => {
render(<ActivityTab householdId="hh1" />);
await waitFor(() => expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.any(Object)));
await waitFor(() =>
expect(mockGetSpendingSummary).toHaveBeenCalledWith('hh1', expect.any(Object)),
);
});
it('fetches events on mount', async () => {
render(<ActivityTab householdId="hh1" />);
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledWith('hh1', expect.any(Object)));
await waitFor(() =>
expect(mockListCabinetEvents).toHaveBeenCalledWith('hh1', expect.any(Object)),
);
});
it('shows empty state when no events', async () => {
@ -103,20 +107,18 @@ describe('ActivityTab', () => {
it('shows spending summary with data', async () => {
mockGetSpendingSummary.mockResolvedValue({
totalSpent: 125.50,
totalSpent: 125.5,
currency: 'USD',
byMedicine: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
totalSpent: 125.50,
totalSpent: 125.5,
purchaseCount: 2,
avgUnitPrice: 0.69,
},
],
byPeriod: [
{ period: '2026-01', totalSpent: 125.50 },
],
byPeriod: [{ period: '2026-01', totalSpent: 125.5 }],
});
render(<ActivityTab householdId="hh1" />);
@ -138,7 +140,9 @@ describe('ActivityTab', () => {
render(<ActivityTab householdId="hh1" />);
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByDisplayValue('All event types'), { target: { value: 'purchased' } });
fireEvent.change(screen.getByDisplayValue('All event types'), {
target: { value: 'purchased' },
});
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalledTimes(2));
});
@ -151,7 +155,9 @@ describe('ActivityTab', () => {
render(<ActivityTab householdId="hh1" />);
await waitFor(() => expect(screen.getAllByDisplayValue('All medicines').length).toBeGreaterThan(1));
await waitFor(() =>
expect(screen.getAllByDisplayValue('All medicines').length).toBeGreaterThan(1),
);
const allMedSelects = screen.getAllByDisplayValue('All medicines');
// The last select is the cabinet events medicine filter
fireEvent.change(allMedSelects[allMedSelects.length - 1]!, { target: { value: 'med-1' } });
@ -163,7 +169,9 @@ describe('ActivityTab', () => {
render(<ActivityTab householdId="hh1" />);
await waitFor(() => expect(mockListCabinetEvents).toHaveBeenCalled());
fireEvent.change(screen.getByDisplayValue('All event types'), { target: { value: 'purchased' } });
fireEvent.change(screen.getByDisplayValue('All event types'), {
target: { value: 'purchased' },
});
await waitFor(() => screen.getByText('Clear filters'));
await userEvent.click(screen.getByText('Clear filters'));

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
const {
mockListCabinetItems,
@ -28,7 +29,11 @@ vi.mock('@/services/cabinet', () => ({
vi.mock('@/services/medicines', () => ({ listMedicines: mockListMedicines }));
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => (
<a href={props.href}>{props.children}</a>
),
}));
import { CabinetTab } from '../CabinetTab';
@ -206,9 +211,7 @@ describe('CabinetTab', () => {
// Submit without selecting a medicine - should show error
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
await waitFor(() =>
expect(screen.getByText('Please select a medicine')).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText('Please select a medicine')).toBeInTheDocument());
});
it('submits AddToCabinetForm successfully', async () => {
@ -241,7 +244,10 @@ describe('CabinetTab', () => {
fireEvent.submit(screen.getByPlaceholderText('Search medicines...').closest('form')!);
await waitFor(() =>
expect(mockCreateCabinetItem).toHaveBeenCalledWith('hh1', expect.objectContaining({ medicineId: 'med-1' })),
expect(mockCreateCabinetItem).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ medicineId: 'med-1' }),
),
);
});
@ -590,7 +596,9 @@ describe('CabinetTab', () => {
it('shows create error when medicine is selected and create fails', async () => {
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
data: [
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
],
pagination: { cursor: null, hasMore: false },
});
mockCreateCabinetItem.mockRejectedValue(new Error('Create failed'));
@ -623,7 +631,9 @@ describe('CabinetTab', () => {
it('waits for medicines to load then selects medicine in form', async () => {
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
data: [
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
],
pagination: { cursor: null, hasMore: false },
});
@ -648,7 +658,9 @@ describe('CabinetTab', () => {
it('shows fallback error when non-Error is thrown during create', async () => {
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' }],
data: [
{ _id: 'med-1', name: 'Metformin', form: 'tablet', strength: 500, strengthUnit: 'mg' },
],
pagination: { cursor: null, hasMore: false },
});
mockCreateCabinetItem.mockRejectedValue('unexpected');

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
const { mockListMedicines, mockCreateMedicine, mockDeleteMedicine } = vi.hoisted(() => ({
mockListMedicines: vi.fn(),
@ -14,7 +15,11 @@ vi.mock('@/services/medicines', () => ({
deleteMedicine: mockDeleteMedicine,
}));
vi.mock('next/link', () => ({ default: (props: any) => <a href={props.href}>{props.children}</a> }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => (
<a href={props.href}>{props.children}</a>
),
}));
import { LibraryTab } from '../LibraryTab';
@ -40,7 +45,10 @@ describe('LibraryTab', () => {
});
it('renders medicine list after load', async () => {
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
mockListMedicines.mockResolvedValue({
data: [medicine],
pagination: { cursor: null, hasMore: false },
});
render(<LibraryTab householdId="hh1" />);
@ -98,11 +106,19 @@ describe('LibraryTab', () => {
await userEvent.type(screen.getByPlaceholderText('500'), '100');
await userEvent.click(screen.getByRole('button', { name: 'Create Medicine' }));
await waitFor(() => expect(mockCreateMedicine).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Aspirin' })));
await waitFor(() =>
expect(mockCreateMedicine).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ name: 'Aspirin' }),
),
);
});
it('deletes medicine after confirmation', async () => {
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
mockListMedicines.mockResolvedValue({
data: [medicine],
pagination: { cursor: null, hasMore: false },
});
mockDeleteMedicine.mockResolvedValue({});
vi.spyOn(window, 'confirm').mockReturnValue(true);
@ -131,9 +147,13 @@ describe('LibraryTab', () => {
// Change category
fireEvent.change(screen.getByDisplayValue('OTC'), { target: { value: 'prescription' } });
// Change notes (covers truthy branch)
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: 'test notes' } });
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
target: { value: 'test notes' },
});
// Clear notes (covers falsy branch → undefined)
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), { target: { value: '' } });
fireEvent.change(screen.getByPlaceholderText('Any additional notes'), {
target: { value: '' },
});
// Verify form is still visible
expect(screen.getByPlaceholderText('e.g. Metformin')).toBeInTheDocument();
@ -150,10 +170,14 @@ describe('LibraryTab', () => {
await waitFor(() => screen.getByText('Metformin'));
// Search filter
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), { target: { value: 'met' } });
fireEvent.change(screen.getByPlaceholderText('Search medicines...'), {
target: { value: 'met' },
});
// Category filter
fireEvent.change(screen.getByDisplayValue('All Categories'), { target: { value: 'prescription' } });
fireEvent.change(screen.getByDisplayValue('All Categories'), {
target: { value: 'prescription' },
});
// Form filter
fireEvent.change(screen.getByDisplayValue('All Forms'), { target: { value: 'tablet' } });
@ -162,7 +186,10 @@ describe('LibraryTab', () => {
});
it('shows fallback error when non-Error is thrown on delete', async () => {
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
mockListMedicines.mockResolvedValue({
data: [medicine],
pagination: { cursor: null, hasMore: false },
});
mockDeleteMedicine.mockRejectedValue('oops');
vi.spyOn(window, 'confirm').mockReturnValue(true);
@ -207,7 +234,10 @@ describe('LibraryTab', () => {
});
it('does not delete medicine if confirmation cancelled', async () => {
mockListMedicines.mockResolvedValue({ data: [medicine], pagination: { cursor: null, hasMore: false } });
mockListMedicines.mockResolvedValue({
data: [medicine],
pagination: { cursor: null, hasMore: false },
});
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<LibraryTab householdId="hh1" />);

View file

@ -50,9 +50,7 @@ describe('OrganizerTab', () => {
it('shows no active regimens message when none exist', async () => {
render(<OrganizerTab householdId="hh1" />);
await waitFor(() =>
expect(screen.getByText(/No active regimens found/)).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText(/No active regimens found/)).toBeInTheDocument());
});
it('shows fill form when active regimens exist', async () => {
@ -69,9 +67,7 @@ describe('OrganizerTab', () => {
it('shows empty fill history', async () => {
render(<OrganizerTab householdId="hh1" />);
await waitFor(() =>
expect(screen.getByText('No fills recorded yet.')).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText('No fills recorded yet.')).toBeInTheDocument());
});
it('renders fill history entries', async () => {
@ -118,7 +114,10 @@ describe('OrganizerTab', () => {
fireEvent.submit(screen.getByText('Preview fill').closest('form')!);
await waitFor(() =>
expect(mockPreviewFill).toHaveBeenCalledWith('hh1', expect.objectContaining({ regimenId: 'reg-1' })),
expect(mockPreviewFill).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ regimenId: 'reg-1' }),
),
);
});
@ -159,7 +158,12 @@ describe('OrganizerTab', () => {
await userEvent.click(screen.getByText('Confirm fill'));
await waitFor(() => expect(mockExecuteFill).toHaveBeenCalledWith('hh1', expect.objectContaining({ regimenId: 'reg-1' })));
await waitFor(() =>
expect(mockExecuteFill).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ regimenId: 'reg-1' }),
),
);
});
it('shows shortage warning in preview', async () => {
@ -291,15 +295,17 @@ describe('OrganizerTab', () => {
status: 'partial',
numberOfDays: 7,
fillDate: '2026-01-01T00:00:00.000Z',
items: [{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 7,
quantityTaken: 3,
wasShort: true,
shortage: 4,
deductions: [],
}],
items: [
{
medicineId: 'med-1',
medicineName: 'Metformin',
quantityNeeded: 7,
quantityTaken: 3,
wasShort: true,
shortage: 4,
deductions: [],
},
],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
},
@ -395,7 +401,11 @@ describe('OrganizerTab', () => {
isShort: false,
shortage: 0,
cabinetBreakdown: [
{ cabinetItemId: 'ci-1', quantityToTake: 7, expirationDate: '2027-06-01T00:00:00.000Z' },
{
cabinetItemId: 'ci-1',
quantityToTake: 7,
expirationDate: '2027-06-01T00:00:00.000Z',
},
{ cabinetItemId: 'ci-2', quantityToTake: 3 },
],
},

View file

@ -2,14 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const { mockListRegimens, mockCreateRegimen, mockUpdateRegimen, mockDeleteRegimen, mockGetBurnRates } =
vi.hoisted(() => ({
mockListRegimens: vi.fn(),
mockCreateRegimen: vi.fn(),
mockUpdateRegimen: vi.fn(),
mockDeleteRegimen: vi.fn(),
mockGetBurnRates: vi.fn(),
}));
const {
mockListRegimens,
mockCreateRegimen,
mockUpdateRegimen,
mockDeleteRegimen,
mockGetBurnRates,
} = vi.hoisted(() => ({
mockListRegimens: vi.fn(),
mockCreateRegimen: vi.fn(),
mockUpdateRegimen: vi.fn(),
mockDeleteRegimen: vi.fn(),
mockGetBurnRates: vi.fn(),
}));
const { mockListMedicines } = vi.hoisted(() => ({ mockListMedicines: vi.fn() }));
@ -62,7 +67,10 @@ describe('RegimensTab', () => {
});
it('renders regimen list', async () => {
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
mockListRegimens.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
@ -122,7 +130,10 @@ describe('RegimensTab', () => {
});
it('deletes regimen after confirmation', async () => {
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
mockListRegimens.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
mockDeleteRegimen.mockResolvedValue({});
vi.spyOn(window, 'confirm').mockReturnValue(true);
@ -142,12 +153,17 @@ describe('RegimensTab', () => {
await waitFor(() => screen.getByText('Burn rate'));
await userEvent.click(screen.getByText('Burn rate'));
await waitFor(() => expect(screen.getByText('Burn Rate & Spending Projections')).toBeInTheDocument());
await waitFor(() =>
expect(screen.getByText('Burn Rate & Spending Projections')).toBeInTheDocument(),
);
expect(mockGetBurnRates).toHaveBeenCalledWith('hh1');
});
it('opens edit form for regimen', async () => {
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
mockListRegimens.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
@ -158,7 +174,10 @@ describe('RegimensTab', () => {
});
it('saves edited regimen', async () => {
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
mockListRegimens.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
mockUpdateRegimen.mockResolvedValue({ ...regimen, name: 'Evening Routine' });
render(<RegimensTab householdId="hh1" />);
@ -167,11 +186,17 @@ describe('RegimensTab', () => {
await userEvent.click(screen.getByTitle('Edit'));
await waitFor(() => screen.getByDisplayValue('Morning Routine'));
fireEvent.change(screen.getByDisplayValue('Morning Routine'), { target: { value: 'Evening Routine' } });
fireEvent.change(screen.getByDisplayValue('Morning Routine'), {
target: { value: 'Evening Routine' },
});
fireEvent.submit(screen.getByDisplayValue('Evening Routine').closest('form')!);
await waitFor(() =>
expect(mockUpdateRegimen).toHaveBeenCalledWith('hh1', 'reg-1', expect.objectContaining({ name: 'Evening Routine' })),
expect(mockUpdateRegimen).toHaveBeenCalledWith(
'hh1',
'reg-1',
expect.objectContaining({ name: 'Evening Routine' }),
),
);
});
@ -196,7 +221,10 @@ describe('RegimensTab', () => {
});
it('shows error when delete fails', async () => {
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
mockListRegimens.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
mockDeleteRegimen.mockRejectedValue(new Error('Delete failed'));
vi.spyOn(window, 'confirm').mockReturnValue(true);
@ -237,7 +265,10 @@ describe('RegimensTab', () => {
});
it('cancels edit form and hides it', async () => {
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
mockListRegimens.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
@ -251,7 +282,10 @@ describe('RegimensTab', () => {
});
it('filters regimens by active status', async () => {
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
mockListRegimens.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);
@ -288,7 +322,9 @@ describe('RegimensTab', () => {
it('changes medicine, dosage, and unit in medication row', async () => {
mockListMedicines.mockResolvedValue({
data: [{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' }],
data: [
{ _id: 'med-1', name: 'Metformin', strength: 500, strengthUnit: 'mg', form: 'tablet' },
],
pagination: { cursor: null, hasMore: false },
});
@ -300,9 +336,11 @@ describe('RegimensTab', () => {
await waitFor(() => screen.getByText('Select medicine...'));
// Select a medicine in the medication row
const medicineSelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).options[0]?.text === 'Select medicine...',
) as HTMLSelectElement;
const medicineSelect = screen
.getAllByRole('combobox')
.find(
(s) => (s as HTMLSelectElement).options[0]?.text === 'Select medicine...',
) as HTMLSelectElement;
expect(medicineSelect).toBeDefined();
fireEvent.change(medicineSelect!, { target: { value: 'med-1' } });
@ -311,9 +349,9 @@ describe('RegimensTab', () => {
if (dosageInput) fireEvent.change(dosageInput, { target: { value: '2' } });
// Change dosage unit
const unitSelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).value === 'tablet',
) as HTMLSelectElement;
const unitSelect = screen
.getAllByRole('combobox')
.find((s) => (s as HTMLSelectElement).value === 'tablet') as HTMLSelectElement;
if (unitSelect) fireEvent.change(unitSelect!, { target: { value: 'capsule' } });
expect(screen.getByPlaceholderText('e.g. Morning routine')).toBeInTheDocument();
@ -342,9 +380,9 @@ describe('RegimensTab', () => {
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
// The frequency select has 'daily' as its first option value
const frequencySelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).options[0]?.value === 'daily',
) as HTMLSelectElement;
const frequencySelect = screen
.getAllByRole('combobox')
.find((s) => (s as HTMLSelectElement).options[0]?.value === 'daily') as HTMLSelectElement;
expect(frequencySelect).toBeDefined();
fireEvent.change(frequencySelect!, { target: { value: 'custom' } });
@ -368,9 +406,9 @@ describe('RegimensTab', () => {
await waitFor(() => screen.getByPlaceholderText('e.g. Morning routine'));
// The time-of-day select has 'Any time' as its first option text
const timeOfDaySelect = screen.getAllByRole('combobox').find(
(s) => (s as HTMLSelectElement).options[0]?.text === 'Any time',
) as HTMLSelectElement;
const timeOfDaySelect = screen
.getAllByRole('combobox')
.find((s) => (s as HTMLSelectElement).options[0]?.text === 'Any time') as HTMLSelectElement;
expect(timeOfDaySelect).toBeDefined();
fireEvent.change(timeOfDaySelect!, { target: { value: 'morning' } });
@ -389,7 +427,10 @@ describe('RegimensTab', () => {
});
it('shows error when update fails', async () => {
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
mockListRegimens.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
mockUpdateRegimen.mockRejectedValue(new Error('Update failed'));
render(<RegimensTab householdId="hh1" />);
@ -401,7 +442,10 @@ describe('RegimensTab', () => {
});
it('toggles active/inactive status', async () => {
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
mockListRegimens.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
mockUpdateRegimen.mockResolvedValue({ ...regimen, isActive: false });
render(<RegimensTab householdId="hh1" />);
@ -452,9 +496,7 @@ describe('RegimensTab', () => {
await waitFor(() => screen.getByDisplayValue('All regimens'));
fireEvent.change(screen.getByDisplayValue('All regimens'), { target: { value: 'active' } });
await waitFor(() =>
expect(screen.getByText('No active regimens found.')).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText('No active regimens found.')).toBeInTheDocument());
});
it('shows null when form is open and regimens list is empty', async () => {
@ -516,7 +558,10 @@ describe('RegimensTab', () => {
});
it('initializes edit form with existing medications', async () => {
mockListRegimens.mockResolvedValue({ data: [regimen], pagination: { cursor: null, hasMore: false } });
mockListRegimens.mockResolvedValue({
data: [regimen],
pagination: { cursor: null, hasMore: false },
});
render(<RegimensTab householdId="hh1" />);

View file

@ -1,10 +1,13 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type React from 'react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => props.children,
}));
vi.mock('@/app/(dashboard)/medicines/ActivityTab', () => ({
ActivityTab: ({ householdId }: { householdId: string }) => (
<div data-testid="activity-tab">{householdId}</div>

View file

@ -1,51 +1,49 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { ActivityTab } from '../ActivityTab';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { PageSkeleton, NoHousehold } from '../helpers';
export default function ActivityPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
<div className="animate-pulse space-y-3">
<div className="h-40 rounded-xl bg-gray-200" />
<div className="h-40 rounded-xl bg-gray-200" />
</div>
</div>
<>
<SetPageHeader
title="Cabinet Activity"
subtitle="Spending and cabinet changes"
crumbs={['Medicines', 'Activity']}
/>
<PageSkeleton />
</>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Cabinet Activity</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before viewing cabinet activity.
</p>
</div>
</div>
<>
<SetPageHeader
title="Cabinet Activity"
subtitle="Spending and cabinet changes"
crumbs={['Medicines', 'Activity']}
/>
<NoHousehold />
</>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
Medicines
</Link>
<span className="text-gray-400">/</span>
<h1 className="text-2xl font-bold">Cabinet Activity</h1>
<>
<SetPageHeader
title="Cabinet Activity"
subtitle="Spending and cabinet changes"
crumbs={['Medicines', 'Activity']}
/>
<div className="mt-page">
<ActivityTab householdId={householdId} />
</div>
<ActivityTab householdId={householdId} />
</div>
</>
);
}

View file

@ -1,10 +1,13 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type React from 'react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => props.children,
}));
vi.mock('@/app/(dashboard)/medicines/CabinetTab', () => ({
CabinetTab: ({ householdId }: { householdId: string }) => (
<div data-testid="cabinet-tab">{householdId}</div>

View file

@ -3,39 +3,77 @@
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { CabinetTab } from '../CabinetTab';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
export default function CabinetPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicine Cabinet</h1>
<div className="animate-pulse space-y-3">
<div className="h-10 w-64 rounded-lg bg-gray-200" />
<div className="h-20 rounded-xl bg-gray-200" />
<div className="h-20 rounded-xl bg-gray-200" />
<>
<SetPageHeader
title="Medicine Cabinet"
subtitle="Everything on hand, with days of supply"
crumbs={['Medicines', 'Cabinet']}
/>
<div style={{ padding: '28px 32px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
height: 64,
borderRadius: 'var(--r-md)',
background: 'var(--bg-inset)',
}}
/>
))}
</div>
</div>
</div>
</>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicine Cabinet</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before managing medicines.
</p>
<>
<SetPageHeader
title="Medicine Cabinet"
subtitle="Everything on hand, with days of supply"
crumbs={['Medicines', 'Cabinet']}
/>
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to{' '}
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
create or join a household
</Link>{' '}
before managing medicines.
</p>
</div>
</div>
</div>
</>
);
}
return <CabinetTab householdId={householdId} />;
return (
<>
<SetPageHeader
title="Medicine Cabinet"
subtitle="Everything on hand, with days of supply"
crumbs={['Medicines', 'Cabinet']}
/>
<div className="mt-page">
<CabinetTab householdId={householdId} />
</div>
</>
);
}

View file

@ -0,0 +1,43 @@
import Link from 'next/link';
export function PageSkeleton() {
return (
<div style={{ padding: '28px 32px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
height: 64,
borderRadius: 'var(--r-md)',
background: 'var(--bg-inset)',
}}
/>
))}
</div>
</div>
);
}
export function NoHousehold() {
return (
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to{' '}
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
create or join a household
</Link>{' '}
before managing medicines.
</p>
</div>
</div>
);
}

View file

@ -1,10 +1,13 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type React from 'react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => props.children,
}));
vi.mock('@/app/(dashboard)/medicines/LibraryTab', () => ({
LibraryTab: ({ householdId }: { householdId: string }) => (
<div data-testid="library-tab">{householdId}</div>

View file

@ -1,41 +1,49 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { LibraryTab } from '../LibraryTab';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { PageSkeleton, NoHousehold } from '../helpers';
export default function LibraryPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicine Library</h1>
<div className="animate-pulse space-y-3">
<div className="h-10 w-64 rounded-lg bg-gray-200" />
<div className="h-20 rounded-xl bg-gray-200" />
<div className="h-20 rounded-xl bg-gray-200" />
</div>
</div>
<>
<SetPageHeader
title="Medicine Library"
subtitle="All known medicines"
crumbs={['Medicines', 'Library']}
/>
<PageSkeleton />
</>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicine Library</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before managing medicines.
</p>
</div>
</div>
<>
<SetPageHeader
title="Medicine Library"
subtitle="All known medicines"
crumbs={['Medicines', 'Library']}
/>
<NoHousehold />
</>
);
}
return <LibraryTab householdId={householdId} />;
return (
<>
<SetPageHeader
title="Medicine Library"
subtitle="All known medicines"
crumbs={['Medicines', 'Library']}
/>
<div className="mt-page">
<LibraryTab householdId={householdId} />
</div>
</>
);
}

View file

@ -1,10 +1,13 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type React from 'react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => props.children,
}));
vi.mock('@/app/(dashboard)/medicines/OrganizerTab', () => ({
OrganizerTab: ({ householdId }: { householdId: string }) => (
<div data-testid="organizer-tab">{householdId}</div>

View file

@ -1,51 +1,49 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { OrganizerTab } from '../OrganizerTab';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { PageSkeleton, NoHousehold } from '../helpers';
export default function OrganizerPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
<div className="animate-pulse space-y-3">
<div className="h-40 rounded-xl bg-gray-200" />
<div className="h-40 rounded-xl bg-gray-200" />
</div>
</div>
<>
<SetPageHeader
title="Pill Organizer"
subtitle="Fill a week of pills at once"
crumbs={['Medicines', 'Organizer']}
/>
<PageSkeleton />
</>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Pill Organizer</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before using the pill organizer.
</p>
</div>
</div>
<>
<SetPageHeader
title="Pill Organizer"
subtitle="Fill a week of pills at once"
crumbs={['Medicines', 'Organizer']}
/>
<NoHousehold />
</>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
Medicines
</Link>
<span className="text-gray-400">/</span>
<h1 className="text-2xl font-bold">Pill Organizer</h1>
<>
<SetPageHeader
title="Pill Organizer"
subtitle="Fill a week of pills at once"
crumbs={['Medicines', 'Organizer']}
/>
<div className="mt-page">
<OrganizerTab householdId={householdId} />
</div>
<OrganizerTab householdId={householdId} />
</div>
</>
);
}

View file

@ -2,82 +2,122 @@
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Icon } from '@/components/ui/Icon';
import type { IconName } from '@/components/ui/Icon';
export default function MedicinesPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return <PageSkeleton />;
return (
<>
<SetPageHeader title="Medicines" subtitle="All known medicines" />
<PageSkeleton />
</>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Medicines</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before managing medicines.
</p>
<>
<SetPageHeader title="Medicines" subtitle="All known medicines" />
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to{' '}
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
create or join a household
</Link>{' '}
before managing medicines.
</p>
</div>
</div>
</div>
</>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Medicines</h1>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<SectionCard
title="Library"
description="Manage your medicines and their products"
href="/medicines/library"
/>
<SectionCard
title="Cabinet"
description="Track your medicine inventory, quantities and expiry dates"
href="/medicines/cabinet"
/>
<SectionCard
title="Regimens"
description="Define daily medication schedules and track dosage frequency"
href="/medicines/regimens"
/>
<SectionCard
title="Organizer"
description="Fill your pill organizer and track cabinet usage"
href="/medicines/organizer"
/>
<SectionCard
title="Activity"
description="View cabinet event history and spending summaries"
href="/medicines/activity"
/>
<SectionCard
title="Stores"
description="Manage pharmacies and stores for price tracking"
href="/stores"
/>
<SectionCard
title="Prices"
description="Track and compare medicine prices across stores"
href="/medicine-prices"
/>
<SectionCard
title="Refills"
description="Get refill alerts and manage shopping lists"
href="/refills"
/>
<SectionCard
title="Purchases"
description="Record medicine purchases and track online orders"
href="/purchases"
/>
<>
<SetPageHeader title="Medicines" subtitle="All known medicines" />
<div style={{ padding: '28px 32px 56px', maxWidth: 1400 }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 14,
}}
>
<SectionCard
title="Library"
description="Manage your medicines and their products"
href="/medicines/library"
icon="pill"
/>
<SectionCard
title="Cabinet"
description="Track your medicine inventory, quantities and expiry dates"
href="/medicines/cabinet"
icon="cabinet"
/>
<SectionCard
title="Schedule"
description="Today's dose log and weekly overview"
href="/medicines/schedule"
icon="clock"
/>
<SectionCard
title="Regimens"
description="Define daily medication schedules"
href="/medicines/regimens"
icon="list"
/>
<SectionCard
title="Organizer"
description="Fill your pill organizer and track cabinet usage"
href="/medicines/organizer"
icon="calendar"
/>
<SectionCard
title="Activity"
description="View cabinet event history and spending summaries"
href="/medicines/activity"
icon="trend"
/>
<SectionCard
title="Stores"
description="Manage pharmacies and stores for price tracking"
href="/stores"
icon="store"
/>
<SectionCard
title="Prices"
description="Track and compare medicine prices across stores"
href="/medicine-prices"
icon="tag"
/>
<SectionCard
title="Refills"
description="Get refill alerts and manage shopping lists"
href="/refills"
icon="refresh"
/>
<SectionCard
title="Purchases"
description="Record medicine purchases and track online orders"
href="/purchases"
icon="truck"
/>
</div>
</div>
</div>
</>
);
}
@ -85,29 +125,64 @@ function SectionCard({
title,
description,
href,
icon,
}: {
title: string;
description: string;
href: string;
icon: IconName;
}) {
return (
<Link
href={href}
className="rounded-xl border bg-white p-6 shadow-sm hover:shadow-md transition-shadow"
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
padding: 18,
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
textDecoration: 'none',
transition: 'all 0.15s',
}}
>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-1 text-sm text-gray-500">{description}</p>
<div
style={{
width: 34,
height: 34,
borderRadius: 'var(--r-sm)',
background: 'var(--brand-soft)',
color: 'var(--brand)',
display: 'grid',
placeItems: 'center',
}}
>
<Icon name={icon} size={16} />
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-strong)' }}>{title}</div>
<div style={{ fontSize: 12, color: 'var(--ink-muted)', marginTop: 2 }}>{description}</div>
</div>
</Link>
);
}
function PageSkeleton() {
return (
<div>
<h1 className="text-2xl font-bold mb-6">Medicines</h1>
<div className="animate-pulse grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div style={{ padding: '28px 32px' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 14,
}}
>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="h-24 rounded-xl bg-gray-200" />
<div
key={i}
style={{ height: 96, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
/>
))}
</div>
</div>

View file

@ -1,10 +1,13 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type React from 'react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => props.children,
}));
vi.mock('@/app/(dashboard)/medicines/RegimensTab', () => ({
RegimensTab: ({ householdId }: { householdId: string }) => (
<div data-testid="regimens-tab">{householdId}</div>

View file

@ -1,52 +1,49 @@
'use client';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { RegimensTab } from '../RegimensTab';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { PageSkeleton, NoHousehold } from '../helpers';
export default function RegimensPage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
<div className="animate-pulse space-y-3">
<div className="h-10 w-64 rounded-lg bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
<div className="h-24 rounded-xl bg-gray-200" />
</div>
</div>
<>
<SetPageHeader
title="Regimens"
subtitle="Daily medication schedules"
crumbs={['Medicines', 'Regimens']}
/>
<PageSkeleton />
</>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Regimens</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before managing regimens.
</p>
</div>
</div>
<>
<SetPageHeader
title="Regimens"
subtitle="Daily medication schedules"
crumbs={['Medicines', 'Regimens']}
/>
<NoHousehold />
</>
);
}
return (
<div>
<div className="flex items-center gap-3 mb-6">
<Link href="/medicines" className="text-sm text-gray-500 hover:text-gray-700">
Medicines
</Link>
<span className="text-gray-400">/</span>
<h1 className="text-2xl font-bold">Regimens</h1>
<>
<SetPageHeader
title="Regimens"
subtitle="Daily medication schedules"
crumbs={['Medicines', 'Regimens']}
/>
<div className="mt-page">
<RegimensTab householdId={householdId} />
</div>
<RegimensTab householdId={householdId} />
</div>
</>
);
}

View file

@ -0,0 +1,297 @@
'use client';
import { useState, useEffect } from 'react';
import { useApi } from '@/lib/useApi';
import { listRegimens } from '@/services/regimens';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { Icon } from '@/components/ui/Icon';
import { PageSkeleton, NoHousehold } from '../helpers';
import Link from 'next/link';
import type { z } from 'zod/v4';
import type { RegimenResponseSchema } from '@meshitrack/shared';
type Regimen = z.infer<typeof RegimenResponseSchema>;
type Medication = Regimen['medications'][number];
const TIME_SLOTS = [
{ key: 'morning', label: 'Morning', icon: 'sun' as const },
{ key: 'afternoon', label: 'Afternoon', icon: 'sun' as const },
{ key: 'evening', label: 'Evening', icon: 'moon' as const },
{ key: 'bedtime', label: 'Bedtime', icon: 'moon' as const },
{ key: 'any', label: 'Any time', icon: 'clock' as const },
] as const;
const FREQUENCY_LABELS: Record<string, string> = {
daily: 'Once daily',
twice_daily: 'Twice daily',
three_times_daily: 'Three times daily',
weekly: 'Weekly',
every_other_day: 'Every other day',
as_needed: 'As needed',
custom: 'Custom',
};
type SlotEntry = { regimen: Regimen; medication: Medication };
function groupByTimeSlot(regimens: Regimen[]): Record<string, SlotEntry[]> {
const groups: Record<string, SlotEntry[]> = {
morning: [],
afternoon: [],
evening: [],
bedtime: [],
any: [],
};
for (const regimen of regimens) {
for (const medication of regimen.medications) {
const slot = medication.timeOfDay ?? 'any';
if (slot in groups) {
groups[slot].push({ regimen, medication });
} else {
groups.any.push({ regimen, medication });
}
}
}
return groups;
}
function MedicationCard({ regimen, medication }: SlotEntry) {
const freqLabel =
medication.frequency === 'custom' && medication.customFrequencyPerDay
? `${medication.customFrequencyPerDay}x daily`
: (FREQUENCY_LABELS[medication.frequency] ?? medication.frequency);
return (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 'var(--r-sm)',
background: 'var(--brand-soft)',
display: 'grid',
placeItems: 'center',
color: 'var(--brand)',
flexShrink: 0,
}}
>
<Icon name="pill" size={18} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-strong)' }}>
{medication.medicineName}{' '}
<span style={{ fontWeight: 400, color: 'var(--ink-muted)' }}>
{medication.medicineStrength} {medication.medicineStrengthUnit}
</span>
</div>
<div style={{ fontSize: 13, color: 'var(--ink-muted)', marginTop: 2 }}>
{medication.dosage} {medication.dosageUnit} &mdash; {freqLabel}
</div>
{medication.instructions && (
<div style={{ fontSize: 12, color: 'var(--ink-faint)', marginTop: 2 }}>
{medication.instructions}
</div>
)}
<div style={{ fontSize: 11, color: 'var(--ink-faint)', marginTop: 4 }}>
<span className="mt-pill mt-pill--ghost">{regimen.name}</span>
</div>
</div>
</div>
);
}
function TimeSlotCard({
label,
icon,
entries,
}: {
slotKey: string;
label: string;
icon: 'sun' | 'moon' | 'clock';
entries: SlotEntry[];
}) {
if (entries.length === 0) return null;
return (
<Card style={{ marginBottom: 16 }}>
<CardHeader
title={label}
subtitle={`${entries.length} dose${entries.length !== 1 ? 's' : ''}`}
action={
<div
style={{
width: 32,
height: 32,
borderRadius: 'var(--r-sm)',
background: 'var(--bg-inset)',
display: 'grid',
placeItems: 'center',
color: 'var(--ink-muted)',
}}
>
<Icon name={icon} size={16} />
</div>
}
/>
<div>
{entries.map(({ regimen, medication }, i) => (
<MedicationCard
key={`${regimen._id}-${medication.medicineId}-${i}`}
regimen={regimen}
medication={medication}
/>
))}
</div>
</Card>
);
}
function ScheduleContent({ householdId }: { householdId: string }) {
const [regimens, setRegimens] = useState<Regimen[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
async function load() {
try {
setLoading(true);
const allRegimens: Regimen[] = [];
let cursor: string | null = null;
do {
const res = await listRegimens(householdId, {
isActive: true,
limit: 100,
...(cursor ? { cursor } : {}),
});
allRegimens.push(...res.data);
cursor = res.pagination.cursor;
} while (cursor);
if (!cancelled) setRegimens(allRegimens);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load regimens');
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, [householdId]);
if (loading) return <PageSkeleton />;
if (error) {
return <div className="mt-alert mt-alert--danger mb-4">{error}</div>;
}
if (regimens.length === 0) {
return (
<Card>
<div
style={{
padding: '48px 24px',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: 'var(--r-md)',
background: 'var(--bg-inset)',
display: 'grid',
placeItems: 'center',
color: 'var(--ink-faint)',
}}
>
<Icon name="clock" size={24} />
</div>
<div style={{ fontSize: 15, color: 'var(--ink-muted)' }}>No active regimens found.</div>
<div style={{ fontSize: 13, color: 'var(--ink-faint)' }}>
<Link href="/medicines/regimens" className="mt-link">
Set up a regimen
</Link>{' '}
to start tracking your daily schedule.
</div>
</div>
</Card>
);
}
const groups = groupByTimeSlot(regimens);
const totalDoses = Object.values(groups).reduce((sum, g) => sum + g.length, 0);
return (
<>
<div style={{ marginBottom: 16, fontSize: 13, color: 'var(--ink-muted)' }}>
{regimens.length} active regimen{regimens.length !== 1 ? 's' : ''} &mdash; {totalDoses} dose
{totalDoses !== 1 ? 's' : ''} per day
</div>
{TIME_SLOTS.map(({ key, label, icon }) => (
<TimeSlotCard
key={key}
slotKey={key}
label={label}
icon={icon}
entries={groups[key] ?? []}
/>
))}
</>
);
}
export default function SchedulePage() {
const { householdId, isLoading: sessionLoading } = useApi();
if (sessionLoading) {
return (
<>
<SetPageHeader
title="Schedule & Log"
subtitle="Today and this week"
crumbs={['Medicines', 'Schedule']}
/>
<PageSkeleton />
</>
);
}
if (!householdId) {
return (
<>
<SetPageHeader
title="Schedule & Log"
subtitle="Today and this week"
crumbs={['Medicines', 'Schedule']}
/>
<NoHousehold />
</>
);
}
return (
<>
<SetPageHeader
title="Schedule & Log"
subtitle="Today and this week"
crumbs={['Medicines', 'Schedule']}
/>
<div style={{ padding: '28px 32px 56px', maxWidth: 900 }}>
<ScheduleContent householdId={householdId} />
</div>
</>
);
}

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
@ -30,7 +31,9 @@ vi.mock('@/services/medicines', () => ({
listMedicines: mockListMedicines,
listMedicineProducts: mockListMedicineProducts,
}));
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => props.children,
}));
import PurchasesPage from '../page';
@ -61,9 +64,7 @@ describe('PurchasesPage', () => {
mockListPurchases.mockResolvedValue(emptyResponse);
render(<PurchasesPage />);
await waitFor(() =>
expect(screen.getByText(/No purchases recorded yet/)).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText(/No purchases recorded yet/)).toBeInTheDocument());
});
it('shows Record Purchase button', async () => {
@ -142,7 +143,15 @@ describe('PurchasesPage', () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
mockListStores.mockResolvedValue({
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
@ -162,7 +171,15 @@ describe('PurchasesPage', () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListPurchases.mockResolvedValue(emptyResponse);
mockListStores.mockResolvedValue({
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
@ -176,7 +193,9 @@ describe('PurchasesPage', () => {
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
await waitFor(() =>
expect(screen.getByText('Add at least one item with a name and quantity.')).toBeInTheDocument(),
expect(
screen.getByText('Add at least one item with a name and quantity.'),
).toBeInTheDocument(),
);
});
@ -293,7 +312,15 @@ describe('PurchasesPage', () => {
mockListPurchases.mockResolvedValue(emptyResponse);
mockListMedicineProducts.mockResolvedValue(emptyResponse);
mockListStores.mockResolvedValue({
data: [{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z' }],
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
},
],
pagination: { cursor: null, hasMore: false },
});
mockCreatePurchase.mockResolvedValue({
@ -314,7 +341,9 @@ describe('PurchasesPage', () => {
await waitFor(() => screen.getByText('Save Purchase'));
fireEvent.change(screen.getByDisplayValue('Select store'), { target: { value: 'st-1' } });
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), { target: { value: 'Aspirin' } });
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), {
target: { value: 'Aspirin' },
});
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
fireEvent.submit(screen.getByRole('button', { name: 'Save Purchase' }).closest('form')!);
@ -457,7 +486,9 @@ describe('PurchasesPage', () => {
await userEvent.click(screen.getByText('Record Purchase'));
await waitFor(() => screen.getByPlaceholderText('Brand / product name'));
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), { target: { value: 'Aspirin' } });
fireEvent.change(screen.getByPlaceholderText('Brand / product name'), {
target: { value: 'Aspirin' },
});
fireEvent.change(screen.getByPlaceholderText('90'), { target: { value: '30' } });
fireEvent.change(screen.getByPlaceholderText('tablet'), { target: { value: 'capsule' } });
@ -472,9 +503,7 @@ describe('PurchasesPage', () => {
pagination: { cursor: null, hasMore: false },
});
mockListMedicineProducts.mockResolvedValue({
data: [
{ _id: 'prod-1', brand: 'Glucophage', packageSize: 60, packageUnit: 'tablet' },
],
data: [{ _id: 'prod-1', brand: 'Glucophage', packageSize: 60, packageUnit: 'tablet' }],
pagination: { cursor: null, hasMore: false },
});

View file

@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import {
listPurchases,
createPurchase,
@ -12,10 +13,7 @@ import {
import { listStores } from '@/services/stores';
import { listMedicines, listMedicineProducts } from '@/services/medicines';
import type { z } from 'zod/v4';
import type {
PurchaseResponseSchema,
PurchaseListResponseSchema,
} from '@meshitrack/shared';
import type { PurchaseResponseSchema } from '@meshitrack/shared';
type PurchaseResponse = z.infer<typeof PurchaseResponseSchema>;
@ -75,13 +73,21 @@ function CreatePurchaseForm({
]);
useEffect(() => {
listMedicines(householdId, { limit: 100 }).then((r) => setMedicines(r.data)).catch(() => {});
listMedicines(householdId, { limit: 100 })
.then((r) => setMedicines(r.data))
.catch(() => {});
}, [householdId]);
async function handleMedicineChange(idx: number, medicineId: string) {
const updated = items.map((item, i) =>
i === idx
? { ...item, medicineId, medicineProductId: '', products: [], productsLoading: !!medicineId }
? {
...item,
medicineId,
medicineProductId: '',
products: [],
productsLoading: !!medicineId,
}
: item,
);
setItems(updated);
@ -90,7 +96,9 @@ function CreatePurchaseForm({
const result = await listMedicineProducts(householdId, medicineId, { limit: 50 });
setItems((prev) =>
prev.map((item, i) =>
i === idx ? { ...item, products: result.data as ProductOption[], productsLoading: false } : item,
i === idx
? { ...item, products: result.data as ProductOption[], productsLoading: false }
: item,
),
);
} catch {
@ -109,7 +117,11 @@ function CreatePurchaseForm({
...item,
medicineProductId: productId,
...(product
? { quantity: String(product.packageSize), unit: product.packageUnit, name: product.brand ?? item.name }
? {
quantity: String(product.packageSize),
unit: product.packageUnit,
name: product.brand ?? item.name,
}
: {}),
};
}),
@ -173,22 +185,18 @@ function CreatePurchaseForm({
}
return (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card mb-6">
<h2 className="text-lg font-semibold mb-4">Record Purchase</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Store</label>
<label className="mt-field-label">Store</label>
<select
value={storeId}
onChange={(e) => setStoreId(e.target.value)}
required
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
<option value="">Select store</option>
{stores.map((s) => (
@ -200,7 +208,7 @@ function CreatePurchaseForm({
{stores.length === 0 && (
<p className="text-xs text-gray-400 mt-1">
No stores yet.{' '}
<Link href="/stores" className="text-primary-600 underline">
<Link href="/stores" className="mt-link">
Add a store first
</Link>
</p>
@ -213,7 +221,7 @@ function CreatePurchaseForm({
id="isOnline"
checked={isOnline}
onChange={(e) => setIsOnline(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="isOnline" className="text-sm font-medium text-gray-700">
Online order (pending arrival)
@ -222,26 +230,20 @@ function CreatePurchaseForm({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes (optional)
</label>
<label className="mt-field-label">Notes (optional)</label>
<input
type="text"
maxLength={1000}
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-700">Items</h3>
<button
type="button"
onClick={addItem}
className="rounded-lg border px-3 py-1 text-xs font-medium hover:bg-gray-50 transition-colors"
>
<button type="button" onClick={addItem} className="mt-btn mt-btn--ghost">
Add item
</button>
</div>
@ -255,7 +257,7 @@ function CreatePurchaseForm({
<button
type="button"
onClick={() => removeItem(idx)}
className="text-xs text-red-500 hover:text-red-700"
className="mt-link text-xs"
>
Remove
</button>
@ -264,13 +266,11 @@ function CreatePurchaseForm({
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Medicine (optional)
</label>
<label className="mt-field-label">Medicine (optional)</label>
<select
value={item.medicineId}
onChange={(e) => handleMedicineChange(idx, e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
>
<option value="">Select medicine</option>
{medicines.map((m) => (
@ -282,9 +282,7 @@ function CreatePurchaseForm({
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Product (optional)
</label>
<label className="mt-field-label">Product (optional)</label>
{item.productsLoading ? (
<div className="animate-pulse h-10 rounded-lg bg-gray-200" />
) : (
@ -292,7 +290,7 @@ function CreatePurchaseForm({
value={item.medicineProductId}
onChange={(e) => handleProductChange(idx, e.target.value)}
disabled={!item.medicineId}
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
className="mt-field"
>
<option value="">
{item.medicineId ? 'Select product' : 'Select medicine first'}
@ -309,9 +307,7 @@ function CreatePurchaseForm({
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="md:col-span-2">
<label className="block text-xs font-medium text-gray-600 mb-1">
Name
</label>
<label className="mt-field-label">Name</label>
<input
type="text"
required
@ -319,20 +315,16 @@ function CreatePurchaseForm({
value={item.name}
onChange={(e) =>
setItems((prev) =>
prev.map((it, i) =>
i === idx ? { ...it, name: e.target.value } : it,
),
prev.map((it, i) => (i === idx ? { ...it, name: e.target.value } : it)),
)
}
placeholder="Brand / product name"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Package size
</label>
<label className="mt-field-label">Package size</label>
<input
type="number"
required
@ -347,36 +339,30 @@ function CreatePurchaseForm({
)
}
placeholder="90"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Unit
</label>
<label className="mt-field-label">Unit</label>
<input
type="text"
required
value={item.unit}
onChange={(e) =>
setItems((prev) =>
prev.map((it, i) =>
i === idx ? { ...it, unit: e.target.value } : it,
),
prev.map((it, i) => (i === idx ? { ...it, unit: e.target.value } : it)),
)
}
placeholder="tablet"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Price (optional)
</label>
<label className="mt-field-label">Price (optional)</label>
<input
type="number"
min={0.01}
@ -390,13 +376,11 @@ function CreatePurchaseForm({
)
}
placeholder="9.99"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Currency
</label>
<label className="mt-field-label">Currency</label>
<input
type="text"
maxLength={10}
@ -409,7 +393,7 @@ function CreatePurchaseForm({
)
}
placeholder="USD"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
</div>
@ -419,18 +403,10 @@ function CreatePurchaseForm({
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
{submitting ? 'Saving...' : 'Save Purchase'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
Cancel
</button>
</div>
@ -453,19 +429,13 @@ function PurchaseCard({
const isOrdered = purchase.status === 'ordered';
return (
<div className="rounded-xl border bg-white p-5 shadow-sm">
<div className={`mt-card ${!isOrdered ? '' : ''}`}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold text-gray-900">{purchase.storeName}</p>
<p className="text-xs text-gray-400 mt-0.5">{formatDate(purchase.purchasedAt)}</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
isOrdered
? 'bg-amber-100 text-amber-700'
: 'bg-green-100 text-green-700'
}`}
>
<span className={`mt-pill ${isOrdered ? 'mt-pill--warn' : 'mt-pill--ok'}`}>
{isOrdered ? 'Pending' : 'Received'}
</span>
</div>
@ -476,31 +446,24 @@ function PurchaseCard({
<span className="text-gray-700">{item.name}</span>
<span className="text-gray-500">
{item.quantity} {item.unit}
{item.actualPrice != null && `${item.actualPrice.toFixed(2)} ${item.currency ?? ''}`}
{item.actualPrice != null &&
`${item.actualPrice.toFixed(2)} ${item.currency ?? ''}`}
</span>
</div>
))}
</div>
{purchase.notes && (
<p className="mt-2 text-xs text-gray-400 italic">{purchase.notes}</p>
)}
{purchase.notes && <p className="mt-2 text-xs text-gray-400 italic">{purchase.notes}</p>}
{(isOrdered || onDelete) && (
<div className="mt-4 flex gap-2">
{isOrdered && onReceive && (
<button
onClick={() => onReceive(purchase._id)}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-700 transition-colors"
>
<button onClick={() => onReceive(purchase._id)} className="mt-btn mt-btn--primary">
Mark as received
</button>
)}
{isOrdered && onDelete && (
<button
onClick={() => onDelete(purchase._id)}
className="rounded-lg border px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
<button onClick={() => onDelete(purchase._id)} className="mt-btn mt-btn--ghost">
Cancel order
</button>
)}
@ -522,7 +485,9 @@ function PurchasesContent({ householdId }: { householdId: string }) {
const [hasMore, setHasMore] = useState(false);
useEffect(() => {
listStores(householdId, { limit: 100 }).then((r) => setStores(r.data)).catch(() => {});
listStores(householdId, { limit: 100 })
.then((r) => setStores(r.data))
.catch(() => {});
}, [householdId]);
const fetchPurchases = useCallback(
@ -534,9 +499,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
cursor: append ? (cursor ?? undefined) : undefined,
limit: 20,
});
setPurchases((prev) =>
append ? [...prev, ...result.data] : result.data,
);
setPurchases((prev) => (append ? [...prev, ...result.data] : result.data));
setCursor(result.pagination.cursor);
setHasMore(result.pagination.hasMore);
} catch (err) {
@ -582,10 +545,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Purchases</h1>
<button
onClick={() => setShowForm(!showForm)}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
<button onClick={() => setShowForm(!showForm)} className="mt-btn mt-btn--primary">
{showForm ? 'Cancel' : 'Record Purchase'}
</button>
</div>
@ -604,7 +564,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
@ -648,7 +608,7 @@ function PurchasesContent({ householdId }: { householdId: string }) {
)}
{purchases.length === 0 && (
<div className="rounded-xl border bg-white p-10 text-center shadow-sm">
<div className="mt-card text-center">
<p className="text-sm text-gray-500">
No purchases recorded yet. Record your first purchase to get started.
</p>
@ -676,33 +636,54 @@ export default function PurchasesPage() {
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Purchases</h1>
<div className="animate-pulse space-y-4">
<div className="h-10 w-48 rounded-lg bg-gray-200" />
<div className="h-28 rounded-xl bg-gray-200" />
<div className="h-28 rounded-xl bg-gray-200" />
<>
<SetPageHeader title="Purchases" subtitle="Order history and pending arrivals" />
<div style={{ padding: '28px 32px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{ height: 64, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
/>
))}
</div>
</div>
</div>
</>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Purchases</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before recording purchases.
</p>
<>
<SetPageHeader title="Purchases" subtitle="Order history and pending arrivals" />
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to{' '}
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
create or join a household
</Link>{' '}
before recording purchases.
</p>
</div>
</div>
</div>
</>
);
}
return <PurchasesContent householdId={householdId} />;
return (
<>
<SetPageHeader title="Purchases" subtitle="Order history and pending arrivals" />
<div className="mt-page">
<PurchasesContent householdId={householdId} />
</div>
</>
);
}

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn() }));
@ -29,7 +30,9 @@ vi.mock('@/services/refills', () => ({
updateRefillListItem: mockUpdateRefillListItem,
addToCabinet: mockAddToCabinet,
}));
vi.mock('next/link', () => ({ default: (props: any) => props.children }));
vi.mock('next/link', () => ({
default: (props: { href?: string; children: React.ReactNode }) => props.children,
}));
import RefillsPage from '../page';
@ -65,17 +68,13 @@ describe('RefillsPage', () => {
it('shows empty state for alerts', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
render(<RefillsPage />);
await waitFor(() =>
expect(screen.getByText(/No medicines running low/)).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText(/No medicines running low/)).toBeInTheDocument());
});
it('shows empty state for refill lists', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
render(<RefillsPage />);
await waitFor(() =>
expect(screen.getByText(/No refill lists yet/)).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText(/No refill lists yet/)).toBeInTheDocument());
});
it('shows error when alerts fail', async () => {
@ -116,7 +115,10 @@ describe('RefillsPage', () => {
fireEvent.submit(screen.getByPlaceholderText('List name').closest('form')!);
await waitFor(() =>
expect(mockCreateRefillList).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Test List' })),
expect(mockCreateRefillList).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ name: 'Test List' }),
),
);
});
@ -274,7 +276,9 @@ describe('RefillsPage', () => {
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
target: { value: 'Auto Refills' },
});
fireEvent.submit(screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!);
fireEvent.submit(
screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!,
);
await waitFor(() =>
expect(mockCreateRefillList).toHaveBeenCalledWith(
@ -293,7 +297,11 @@ describe('RefillsPage', () => {
await userEvent.click(screen.getByText('New List'));
await waitFor(() => screen.getByPlaceholderText('List name'));
await userEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[screen.getAllByRole('button', { name: 'Cancel' }).length - 1]!);
await userEvent.click(
screen.getAllByRole('button', { name: 'Cancel' })[
screen.getAllByRole('button', { name: 'Cancel' }).length - 1
]!,
);
expect(screen.queryByPlaceholderText('List name')).not.toBeInTheDocument();
});
@ -382,7 +390,9 @@ describe('RefillsPage', () => {
fireEvent.change(screen.getByPlaceholderText('List name, e.g. Weekly refills'), {
target: { value: 'Auto Refills' },
});
fireEvent.submit(screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!);
fireEvent.submit(
screen.getByPlaceholderText('List name, e.g. Weekly refills').closest('form')!,
);
await waitFor(() => expect(screen.getByText('Generate failed')).toBeInTheDocument());
});
@ -451,7 +461,9 @@ describe('RefillsPage', () => {
await waitFor(() => screen.getByText('Aspirin'));
await userEvent.click(screen.getByRole('checkbox'));
expect(mockUpdateRefillListItem).toHaveBeenCalledWith('hh1', 'rl-1', 'item-1', { checked: true });
expect(mockUpdateRefillListItem).toHaveBeenCalledWith('hh1', 'rl-1', 'item-1', {
checked: true,
});
});
it('marks a shopping list as complete', async () => {
@ -623,9 +635,7 @@ describe('RefillsPage', () => {
await waitFor(() => screen.getByText('Aspirin'));
await userEvent.click(screen.getAllByRole('checkbox')[0]!);
await waitFor(() =>
expect(screen.getByText('Failed to update item')).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText('Failed to update item')).toBeInTheDocument());
});
it('shows fallback error when non-Error thrown on update status', async () => {
@ -653,9 +663,7 @@ describe('RefillsPage', () => {
await waitFor(() => screen.getByText('Start shopping'));
await userEvent.click(screen.getByText('Start shopping'));
await waitFor(() =>
expect(screen.getByText('Failed to update status')).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText('Failed to update status')).toBeInTheDocument());
});
it('shows refill alert when present', async () => {

View file

@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import {
getRefillAlerts,
listRefillLists,
@ -26,11 +27,11 @@ const STATUS_LABELS: Record<string, string> = {
archived: 'Archived',
};
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-700',
shopping: 'bg-blue-100 text-blue-700',
completed: 'bg-gray-100 text-gray-600',
archived: 'bg-gray-100 text-gray-400',
const STATUS_PILL: Record<string, string> = {
active: 'mt-pill--ok',
shopping: 'mt-pill--info',
completed: 'mt-pill--ghost',
archived: 'mt-pill--ghost',
};
function formatDate(dateStr: string): string {
@ -93,7 +94,7 @@ function AlertsPanel({
}
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<h2 className="text-lg font-semibold">Refill Alerts</h2>
<div className="flex items-center gap-3">
@ -102,7 +103,8 @@ function AlertsPanel({
<select
value={thresholdDays}
onChange={(e) => setThresholdDays(Number(e.target.value))}
className="rounded-lg border px-2 py-1 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
{[3, 5, 7, 10, 14, 30].map((d) => (
<option key={d} value={d}>
@ -114,7 +116,7 @@ function AlertsPanel({
{alerts.length > 0 && (
<button
onClick={() => setShowGenerateForm(!showGenerateForm)}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
className="mt-btn mt-btn--primary"
>
Generate Refill List
</button>
@ -131,19 +133,15 @@ function AlertsPanel({
value={listName}
onChange={(e) => setListName(e.target.value)}
placeholder="List name, e.g. Weekly refills"
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
<button
type="submit"
disabled={generating}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
<button type="submit" disabled={generating} className="mt-btn mt-btn--primary">
{generating ? 'Creating...' : 'Create'}
</button>
<button
type="button"
onClick={() => setShowGenerateForm(false)}
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
className="mt-btn mt-btn--ghost"
>
Cancel
</button>
@ -151,7 +149,7 @@ function AlertsPanel({
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
@ -169,9 +167,7 @@ function AlertsPanel({
<div className="py-6 text-center text-sm text-gray-500">
No medicines running low within {thresholdDays} days.
{thresholdDays < 30 && (
<span className="block mt-1 text-xs">
Try increasing the threshold to see more.
</span>
<span className="block mt-1 text-xs">Try increasing the threshold to see more.</span>
)}
</div>
) : (
@ -185,10 +181,7 @@ function AlertsPanel({
: 'text-yellow-600';
return (
<div
key={alert.medicineId}
className="rounded-lg border bg-gray-50 p-4"
>
<div key={alert.medicineId} className="rounded-lg border bg-gray-50 p-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h3 className="font-semibold text-gray-900">
@ -201,17 +194,14 @@ function AlertsPanel({
<span className={daysColor}>
{alert.daysUntilEmpty} day{alert.daysUntilEmpty !== 1 ? 's' : ''} left
</span>
<span className="text-gray-500">
{alert.currentStock} in cabinet
</span>
<span className="text-gray-500">
{alert.dailyConsumption.toFixed(2)}/day
</span>
<span className="text-gray-500">{alert.currentStock} in cabinet</span>
<span className="text-gray-500">{alert.dailyConsumption.toFixed(2)}/day</span>
</div>
</div>
<div className="text-right text-sm">
<p className="text-gray-600">
Suggested: <span className="font-medium">{alert.suggestedQuantity} units</span>
Suggested:{' '}
<span className="font-medium">{alert.suggestedQuantity} units</span>
</p>
{alert.cheapestOption && (
<p className="text-green-700 font-medium">
@ -260,9 +250,7 @@ function RefillListDetail({
const updated = await updateRefillListItem(householdId, list._id, item._id, {
checked: !item.checked,
actualPrice:
!item.checked && actualPrices[item._id]
? Number(actualPrices[item._id])
: undefined,
!item.checked && actualPrices[item._id] ? Number(actualPrices[item._id]) : undefined,
});
setItems(updated.items);
} catch (err) {
@ -287,7 +275,10 @@ function RefillListDetail({
setError('No checked items to add to cabinet.');
return;
}
if (!confirm(`Add ${checkedCount} checked item${checkedCount !== 1 ? 's' : ''} to your cabinet?`)) return;
if (
!confirm(`Add ${checkedCount} checked item${checkedCount !== 1 ? 's' : ''} to your cabinet?`)
)
return;
setAdding(true);
setError('');
try {
@ -295,7 +286,9 @@ function RefillListDetail({
onUpdated();
setAdding(false);
if (result.addedCount > 0) {
alert(`Added ${result.addedCount} item${result.addedCount !== 1 ? 's' : ''} to your cabinet.`);
alert(
`Added ${result.addedCount} item${result.addedCount !== 1 ? 's' : ''} to your cabinet.`,
);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add to cabinet');
@ -307,12 +300,12 @@ function RefillListDetail({
const totalChecked = items.filter((i) => i.checked).length;
return (
<div className="rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">{list.name}</h2>
<div className="flex items-center gap-2 mt-1">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_COLORS[list.status] ?? STATUS_COLORS['active']}`}>
<span className={`mt-pill ${STATUS_PILL[list.status] ?? STATUS_PILL['active']}`}>
{STATUS_LABELS[list.status] ?? list.status}
</span>
<span className="text-xs text-gray-400">
@ -325,21 +318,24 @@ function RefillListDetail({
)}
</div>
</div>
<button
onClick={onClose}
className="rounded p-1 text-gray-400 hover:text-gray-600 transition-colors"
title="Close"
>
<button onClick={onClose} className="mt-btn mt-btn--icon" title="Close">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">Dismiss</button>
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
@ -357,24 +353,24 @@ function RefillListDetail({
checked={item.checked}
onChange={() => handleToggleItem(item)}
disabled={item.addedToCabinet}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
className="h-4 w-4 rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-sm font-medium ${item.checked ? 'line-through text-gray-400' : 'text-gray-900'}`}>
<span
className={`text-sm font-medium ${item.checked ? 'line-through text-gray-400' : 'text-gray-900'}`}
>
{item.medicineName}
</span>
<span className="text-xs text-gray-500">
{item.quantity} {item.unit}
</span>
{item.estimatedPrice != null && (
<span className="text-xs text-gray-400">est. {item.estimatedPrice.toFixed(2)}</span>
)}
{item.addedToCabinet && (
<span className="rounded-full bg-green-100 text-green-700 px-2 py-0.5 text-xs">
in cabinet
<span className="text-xs text-gray-400">
est. {item.estimatedPrice.toFixed(2)}
</span>
)}
{item.addedToCabinet && <span className="mt-pill mt-pill--ok">in cabinet</span>}
</div>
{item.notes && <p className="text-xs text-gray-400 mt-0.5">{item.notes}</p>}
</div>
@ -389,7 +385,8 @@ function RefillListDetail({
setActualPrices((prev) => ({ ...prev, [item._id]: e.target.value }))
}
placeholder="Actual price"
className="w-28 rounded-lg border px-2 py-1 text-xs focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: '7rem', fontSize: '0.75rem' }}
/>
</div>
)}
@ -400,18 +397,14 @@ function RefillListDetail({
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
{checkedNotAdded > 0 && (
<button
onClick={handleAddToCabinet}
disabled={adding}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
<button onClick={handleAddToCabinet} disabled={adding} className="mt-btn mt-btn--primary">
{adding ? 'Adding...' : `Add ${checkedNotAdded} to Cabinet`}
</button>
)}
{list.status === RefillListStatus.ACTIVE && (
<button
onClick={() => handleUpdateStatus(RefillListStatus.SHOPPING)}
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
className="mt-btn mt-btn--ghost"
>
Start shopping
</button>
@ -419,16 +412,15 @@ function RefillListDetail({
{list.status === RefillListStatus.SHOPPING && (
<button
onClick={() => handleUpdateStatus(RefillListStatus.COMPLETED)}
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
className="mt-btn mt-btn--ghost"
>
Mark complete
</button>
)}
{(list.status === RefillListStatus.ACTIVE ||
list.status === RefillListStatus.SHOPPING) && (
{(list.status === RefillListStatus.ACTIVE || list.status === RefillListStatus.SHOPPING) && (
<button
onClick={() => handleUpdateStatus(RefillListStatus.ARCHIVED)}
className="rounded-lg border px-3 py-2 text-sm text-gray-500 hover:bg-gray-50 transition-colors"
className="mt-btn mt-btn--ghost"
>
Archive
</button>
@ -458,7 +450,11 @@ function CreateListForm({
setError('');
setSubmitting(true);
try {
await createRefillList(householdId, { name: name.trim(), fromAlerts: false, thresholdDays: 7 });
await createRefillList(householdId, {
name: name.trim(),
fromAlerts: false,
thresholdDays: 7,
});
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create list');
@ -468,13 +464,9 @@ function CreateListForm({
}
return (
<div className="mb-4 rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card mb-4">
<h3 className="text-base font-semibold mb-3">New Refill List</h3>
{error && (
<div className="mb-3 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{error && <div className="mt-alert mt-alert--danger mb-3">{error}</div>}
<form onSubmit={handleSubmit} className="flex items-center gap-3">
<input
type="text"
@ -483,20 +475,12 @@ function CreateListForm({
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="List name"
className="flex-1 rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
{submitting ? 'Creating...' : 'Create'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border px-3 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
Cancel
</button>
</form>
@ -546,7 +530,8 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All statuses</option>
{Object.values(RefillListStatus).map((s) => (
@ -555,10 +540,7 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
</option>
))}
</select>
<button
onClick={() => setShowForm(!showForm)}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
>
<button onClick={() => setShowForm(!showForm)} className="mt-btn mt-btn--primary">
{showForm ? 'Cancel' : 'New List'}
</button>
</div>
@ -591,9 +573,11 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">Dismiss</button>
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
</button>
</div>
)}
@ -604,8 +588,10 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
))}
</div>
) : lists.length === 0 ? (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-sm text-gray-500">
{filterStatus ? `No ${STATUS_LABELS[filterStatus] ?? filterStatus} lists.` : 'No refill lists yet. Create one above or generate from alerts.'}
<div className="mt-card text-center" style={{ color: 'var(--ink-muted)' }}>
{filterStatus
? `No ${STATUS_LABELS[filterStatus] ?? filterStatus} lists.`
: 'No refill lists yet. Create one above or generate from alerts.'}
</div>
) : (
<div className="space-y-2">
@ -617,18 +603,16 @@ function RefillListsPanel({ householdId }: { householdId: string }) {
<button
key={list._id}
onClick={() => handleSelectList(list)}
className={`w-full rounded-xl border p-4 text-left transition-colors ${
isSelected
? 'bg-primary-50 border-primary-300'
: 'bg-white hover:bg-gray-50'
} shadow-sm`}
className={`w-full mt-card text-left transition-colors ${
isSelected ? 'outline outline-2 outline-[var(--brand)]' : ''
}`}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="font-medium text-gray-900 truncate">{list.name}</span>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_COLORS[list.status] ?? STATUS_COLORS['active']}`}
className={`mt-pill ${STATUS_PILL[list.status] ?? STATUS_PILL['active']}`}
>
{STATUS_LABELS[list.status] ?? list.status}
</span>
@ -658,13 +642,9 @@ function RefillsContent({ householdId }: { householdId: string }) {
return (
<div>
<h1 className="text-2xl font-bold mb-6">Refills</h1>
<div className="space-y-6">
<AlertsPanel
householdId={householdId}
onGenerateList={() => setListsKey((k) => k + 1)}
/>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<AlertsPanel householdId={householdId} onGenerateList={() => setListsKey((k) => k + 1)} />
<div className="mt-card">
<RefillListsPanel key={listsKey} householdId={householdId} />
</div>
</div>
@ -677,32 +657,54 @@ export default function RefillsPage() {
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Refills</h1>
<div className="animate-pulse space-y-4">
<div className="h-40 rounded-xl bg-gray-200" />
<div className="h-60 rounded-xl bg-gray-200" />
<>
<SetPageHeader title="Refills" subtitle="Running-low alerts and shopping lists" />
<div style={{ padding: '28px 32px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[1, 2].map((i) => (
<div
key={i}
style={{ height: 96, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
/>
))}
</div>
</div>
</div>
</>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Refills</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before managing refills.
</p>
<>
<SetPageHeader title="Refills" subtitle="Running-low alerts and shopping lists" />
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to{' '}
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
create or join a household
</Link>{' '}
before managing refills.
</p>
</div>
</div>
</div>
</>
);
}
return <RefillsContent householdId={householdId} />;
return (
<>
<SetPageHeader title="Refills" subtitle="Running-low alerts and shopping lists" />
<div className="mt-page">
<RefillsContent householdId={householdId} />
</div>
</>
);
}

View file

@ -6,14 +6,19 @@ const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockCreateHousehold, mockJoinHousehold, mockGetHousehold, mockUpdateHousehold, mockGenerateInviteCode } =
vi.hoisted(() => ({
mockCreateHousehold: vi.fn(),
mockJoinHousehold: vi.fn(),
mockGetHousehold: vi.fn(),
mockUpdateHousehold: vi.fn(),
mockGenerateInviteCode: vi.fn(),
}));
const {
mockCreateHousehold,
mockJoinHousehold,
mockGetHousehold,
mockUpdateHousehold,
mockGenerateInviteCode,
} = vi.hoisted(() => ({
mockCreateHousehold: vi.fn(),
mockJoinHousehold: vi.fn(),
mockGetHousehold: vi.fn(),
mockUpdateHousehold: vi.fn(),
mockGenerateInviteCode: vi.fn(),
}));
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/households', () => ({
@ -137,7 +142,9 @@ describe('SettingsPage', () => {
await userEvent.type(input, 'Updated Home');
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(mockUpdateHousehold).toHaveBeenCalledWith('hh1', { name: 'Updated Home' }));
await waitFor(() =>
expect(mockUpdateHousehold).toHaveBeenCalledWith('hh1', { name: 'Updated Home' }),
);
});
it('shows error when name update fails', async () => {
@ -252,7 +259,9 @@ describe('SettingsPage', () => {
await waitFor(() => screen.getByText('My House'));
await userEvent.click(screen.getByRole('button', { name: 'Regenerate' }));
await waitFor(() => expect(screen.getByText('Failed to regenerate invite code')).toBeInTheDocument());
await waitFor(() =>
expect(screen.getByText('Failed to regenerate invite code')).toBeInTheDocument(),
);
});
it('shows validation error when saving empty name', async () => {

View file

@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import {
createHousehold,
joinHousehold,
@ -14,26 +15,32 @@ export default function SettingsPage() {
const { householdId, isLoading, refreshProfile } = useApi();
if (isLoading) {
return <SettingsLoading />;
return (
<>
<SetPageHeader title="Settings" subtitle="Household and account" />
<SettingsLoading />
</>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-4">Settings</h1>
<div className="max-w-2xl space-y-6">
<HouseholdSection householdId={householdId} onHouseholdChanged={() => refreshProfile()} />
<AccountSection />
<>
<SetPageHeader title="Settings" subtitle="Household and account" />
<div style={{ padding: '28px 32px 56px' }}>
<div style={{ maxWidth: 640, display: 'flex', flexDirection: 'column', gap: 24 }}>
<HouseholdSection householdId={householdId} onHouseholdChanged={() => refreshProfile()} />
<AccountSection />
</div>
</div>
</div>
</>
);
}
function SettingsLoading() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Settings</h1>
<div className="max-w-2xl">
<div className="animate-pulse rounded-xl border bg-white p-6 shadow-sm h-48" />
<div style={{ padding: '28px 32px' }}>
<div style={{ maxWidth: 640 }}>
<div style={{ height: 192, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }} />
</div>
</div>
);
@ -64,20 +71,23 @@ function HouseholdSection({
const [regenerating, setRegenerating] = useState(false);
const [regenerateError, setRegenerateError] = useState('');
async function loadHousehold() {
if (!householdId || /* v8 ignore next */ loadedHousehold) return;
try {
const hh = await getHousehold(householdId);
setCurrentHousehold(hh);
} catch {
// Household may not be accessible yet
useEffect(() => {
if (!householdId || loadedHousehold) return;
let cancelled = false;
async function loadHousehold() {
try {
const hh = await getHousehold(householdId as string);
if (!cancelled) setCurrentHousehold(hh);
} catch {
// Household may not be accessible yet
}
if (!cancelled) setLoadedHousehold(true);
}
setLoadedHousehold(true);
}
if (householdId && !loadedHousehold) {
loadHousehold();
}
void loadHousehold();
return () => {
cancelled = true;
};
}, [householdId, loadedHousehold]);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();

View file

@ -6,12 +6,14 @@ const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockListStores, mockCreateStore, mockUpdateStore, mockDeactivateStore } = vi.hoisted(() => ({
mockListStores: vi.fn(),
mockCreateStore: vi.fn(),
mockUpdateStore: vi.fn(),
mockDeactivateStore: vi.fn(),
}));
const { mockListStores, mockCreateStore, mockUpdateStore, mockDeactivateStore } = vi.hoisted(
() => ({
mockListStores: vi.fn(),
mockCreateStore: vi.fn(),
mockUpdateStore: vi.fn(),
mockDeactivateStore: vi.fn(),
}),
);
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
@ -151,7 +153,15 @@ describe('StoresPage', () => {
it('creates a store on form submit', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
mockCreateStore.mockResolvedValue({ _id: 'st-new', name: 'Walmart', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' });
mockCreateStore.mockResolvedValue({
_id: 'st-new',
name: 'Walmart',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
});
render(<StoresPage />);
@ -159,11 +169,16 @@ describe('StoresPage', () => {
await userEvent.click(screen.getByText('Add Store'));
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), { target: { value: 'Walmart' } });
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), {
target: { value: 'Walmart' },
});
fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!);
await waitFor(() =>
expect(mockCreateStore).toHaveBeenCalledWith('hh1', expect.objectContaining({ name: 'Walmart' })),
expect(mockCreateStore).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ name: 'Walmart' }),
),
);
});
@ -178,7 +193,9 @@ describe('StoresPage', () => {
await userEvent.click(screen.getByText('Add Store'));
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), { target: { value: 'Walmart' } });
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), {
target: { value: 'Walmart' },
});
fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!);
await waitFor(() => expect(screen.getByText('Store already exists')).toBeInTheDocument());
@ -187,7 +204,17 @@ describe('StoresPage', () => {
it('opens edit form for a store', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
@ -203,7 +230,17 @@ describe('StoresPage', () => {
it('saves edited store', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
mockUpdateStore.mockResolvedValue({});
@ -218,7 +255,11 @@ describe('StoresPage', () => {
fireEvent.submit(screen.getByDisplayValue('CVS Pharmacy').closest('form')!);
await waitFor(() =>
expect(mockUpdateStore).toHaveBeenCalledWith('hh1', 'st-1', expect.objectContaining({ name: 'CVS Pharmacy' })),
expect(mockUpdateStore).toHaveBeenCalledWith(
'hh1',
'st-1',
expect.objectContaining({ name: 'CVS Pharmacy' }),
),
);
});
@ -237,7 +278,17 @@ describe('StoresPage', () => {
it('shows error when deactivate fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
mockDeactivateStore.mockRejectedValue(new Error('Deactivate failed'));
@ -261,7 +312,11 @@ describe('StoresPage', () => {
await userEvent.click(screen.getByText('Add Store'));
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
await userEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[screen.getAllByRole('button', { name: 'Cancel' }).length - 1]!);
await userEvent.click(
screen.getAllByRole('button', { name: 'Cancel' })[
screen.getAllByRole('button', { name: 'Cancel' }).length - 1
]!,
);
expect(screen.queryByPlaceholderText('e.g. Walgreens')).not.toBeInTheDocument();
});
@ -269,7 +324,17 @@ describe('StoresPage', () => {
it('cancels the edit store form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
@ -288,8 +353,24 @@ describe('StoresPage', () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{ _id: 'st-1', name: 'Walgreens', tags: ['pharmacy'], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
{ _id: 'st-2', name: 'Costco', tags: ['supermarket'], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
{
_id: 'st-1',
name: 'Walgreens',
tags: ['pharmacy'],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
{
_id: 'st-2',
name: 'Costco',
tags: ['supermarket'],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
@ -315,8 +396,12 @@ describe('StoresPage', () => {
await userEvent.click(screen.getByText('Add Store'));
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
fireEvent.change(screen.getByPlaceholderText('123 Main St'), { target: { value: '456 Oak Ave' } });
fireEvent.change(screen.getByPlaceholderText('Any notes'), { target: { value: 'Good prices' } });
fireEvent.change(screen.getByPlaceholderText('123 Main St'), {
target: { value: '456 Oak Ave' },
});
fireEvent.change(screen.getByPlaceholderText('Any notes'), {
target: { value: 'Good prices' },
});
expect(screen.getByPlaceholderText('e.g. Walgreens')).toBeInTheDocument();
});
@ -368,8 +453,24 @@ describe('StoresPage', () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{ _id: 'st-1', name: 'Walgreens', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
{ _id: 'st-2', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
{
_id: 'st-1',
name: 'Walgreens',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
{
_id: 'st-2',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
@ -386,7 +487,17 @@ describe('StoresPage', () => {
it('toggles isActive in edit form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [{ _id: 'st-1', name: 'CVS', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' }],
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
mockUpdateStore.mockResolvedValue({});
@ -406,8 +517,24 @@ describe('StoresPage', () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{ _id: 'st-1', name: 'Active Store', tags: [], isActive: true, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
{ _id: 'st-2', name: 'Old Store', tags: [], isActive: false, createdAt: '2026-01-01T00:00:00.000Z', householdId: 'hh1', createdBy: 'u-1' },
{
_id: 'st-1',
name: 'Active Store',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
{
_id: 'st-2',
name: 'Old Store',
tags: [],
isActive: false,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});

View file

@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useApi } from '@/lib/useApi';
import { SetPageHeader } from '@/components/layout/SetPageHeader';
import { listStores, createStore, updateStore, deactivateStore } from '@/services/stores';
import type { z } from 'zod/v4';
import type { StoreResponseSchema } from '@meshitrack/shared';
@ -11,12 +12,12 @@ type Store = z.infer<typeof StoreResponseSchema>;
const PRESET_TAGS = ['pharmacy', 'grocery', 'online', 'bulk', 'discount'];
const TAG_COLORS: Record<string, string> = {
pharmacy: 'bg-blue-100 text-blue-700',
grocery: 'bg-green-100 text-green-700',
online: 'bg-purple-100 text-purple-700',
bulk: 'bg-orange-100 text-orange-700',
discount: 'bg-yellow-100 text-yellow-700',
const TAG_PILL: Record<string, string> = {
pharmacy: 'mt-pill--info',
grocery: 'mt-pill--ok',
online: 'mt-pill--brand',
bulk: 'mt-pill--warn',
discount: 'mt-pill--warn',
};
function formatDate(dateStr: string): string {
@ -83,13 +84,9 @@ function StoreForm({
}
return (
<div className="mb-6 rounded-xl border bg-white p-6 shadow-sm">
<div className="mt-card mb-6">
<h2 className="text-lg font-semibold mb-4">{initial ? 'Edit Store' : 'Add Store'}</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{error && <div className="mt-alert mt-alert--danger mb-4">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
@ -101,7 +98,7 @@ function StoreForm({
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Walgreens"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
@ -114,7 +111,7 @@ function StoreForm({
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
@ -126,20 +123,18 @@ function StoreForm({
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://walgreens.com"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes (optional)
</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes (optional)</label>
<input
type="text"
maxLength={1000}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any notes"
className="w-full rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
/>
</div>
</div>
@ -154,7 +149,8 @@ function StoreForm({
onClick={() => toggleTag(tag)}
className={`rounded-full px-3 py-1 text-xs font-medium border transition-colors ${
tags.includes(tag)
? (TAG_COLORS[tag] ?? 'bg-gray-200 text-gray-800') + ' border-transparent'
? (TAG_PILL[tag] ? `mt-pill ${TAG_PILL[tag]}` : 'bg-gray-200 text-gray-800') +
' border-transparent'
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'
}`}
>
@ -196,13 +192,10 @@ function StoreForm({
}}
placeholder="Custom tag..."
maxLength={50}
className="rounded-lg border px-3 py-1.5 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
/>
<button
type="button"
onClick={addCustomTag}
className="rounded-lg border px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
>
<button type="button" onClick={addCustomTag} className="mt-btn mt-btn--ghost">
Add
</button>
</div>
@ -215,7 +208,7 @@ function StoreForm({
id="isActive"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
Active
@ -224,18 +217,10 @@ function StoreForm({
)}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
<button type="submit" disabled={submitting} className="mt-btn mt-btn--primary">
{submitting ? 'Saving...' : initial ? 'Save changes' : 'Add Store'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<button type="button" onClick={onCancel} className="mt-btn mt-btn--ghost">
Cancel
</button>
</div>
@ -256,40 +241,29 @@ function StoreCard({
onDeactivate: (store: Store) => void;
}) {
return (
<div className={`rounded-xl border bg-white p-4 shadow-sm ${!store.isActive ? 'opacity-60' : ''}`}>
<div className={`mt-card ${!store.isActive ? 'opacity-60' : ''}`}>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="font-semibold text-gray-900">{store.name}</h3>
{!store.isActive && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
Inactive
</span>
)}
{!store.isActive && <span className="mt-pill mt-pill--ghost">Inactive</span>}
</div>
{store.address && (
<p className="text-sm text-gray-500 mb-1">{store.address}</p>
)}
{store.address && <p className="text-sm text-gray-500 mb-1">{store.address}</p>}
{store.url && (
<a
href={store.url}
target="_blank"
rel="noreferrer"
className="text-xs text-primary-600 underline hover:text-primary-700 block mb-1"
className="mt-link text-xs block mb-1"
>
{store.url}
</a>
)}
{store.notes && (
<p className="text-xs text-gray-400 mb-1">{store.notes}</p>
)}
{store.notes && <p className="text-xs text-gray-400 mb-1">{store.notes}</p>}
{store.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{store.tags.map((tag) => (
<span
key={tag}
className={`rounded-full px-2 py-0.5 text-xs font-medium ${TAG_COLORS[tag] ?? 'bg-gray-100 text-gray-600'}`}
>
<span key={tag} className={`mt-pill ${TAG_PILL[tag] ?? 'mt-pill--ghost'}`}>
{tag}
</span>
))}
@ -298,11 +272,7 @@ function StoreCard({
<p className="text-xs text-gray-400 mt-2">Added {formatDate(store.createdAt)}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => onEdit(store)}
className="rounded p-1.5 text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
title="Edit"
>
<button onClick={() => onEdit(store)} className="mt-btn mt-btn--icon" title="Edit">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@ -315,7 +285,7 @@ function StoreCard({
{store.isActive && (
<button
onClick={() => onDeactivate(store)}
className="rounded p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
className="mt-btn mt-btn--danger-icon"
title="Deactivate"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -387,14 +357,14 @@ function StoresContent({ householdId }: { householdId: string }) {
setEditingStore(null);
setShowForm(!showForm);
}}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
className="mt-btn mt-btn--primary"
>
{showForm ? 'Cancel' : 'Add Store'}
</button>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<div className="mt-alert mt-alert--danger mb-4">
{error}
<button onClick={() => setError('')} className="ml-2 underline">
Dismiss
@ -431,12 +401,13 @@ function StoresContent({ householdId }: { householdId: string }) {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search stores..."
className="w-full max-w-xs rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field max-w-xs"
/>
<select
value={filterTag}
onChange={(e) => setFilterTag(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none"
className="mt-field"
style={{ width: 'auto' }}
>
<option value="">All tags</option>
{PRESET_TAGS.map((tag) => (
@ -450,7 +421,7 @@ function StoresContent({ householdId }: { householdId: string }) {
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
className="h-4 w-4 rounded border-gray-300"
/>
Show inactive
</label>
@ -463,8 +434,10 @@ function StoresContent({ householdId }: { householdId: string }) {
))}
</div>
) : visible.length === 0 ? (
<div className="rounded-xl border bg-white p-6 shadow-sm text-center text-gray-500">
{search || filterTag ? 'No stores match your filters.' : 'No stores yet. Add your first one above.'}
<div className="mt-card text-center" style={{ color: 'var(--ink-muted)' }}>
{search || filterTag
? 'No stores match your filters.'
: 'No stores yet. Add your first one above.'}
</div>
) : (
<div className="space-y-3">
@ -490,33 +463,54 @@ export default function StoresPage() {
if (sessionLoading) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Stores</h1>
<div className="animate-pulse space-y-3">
<div className="h-10 w-48 rounded-lg bg-gray-200" />
<div className="h-20 rounded-xl bg-gray-200" />
<div className="h-20 rounded-xl bg-gray-200" />
<>
<SetPageHeader title="Stores" subtitle="Pharmacies and vendors" />
<div style={{ padding: '28px 32px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{ height: 64, borderRadius: 'var(--r-md)', background: 'var(--bg-inset)' }}
/>
))}
</div>
</div>
</div>
</>
);
}
if (!householdId) {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Stores</h1>
<div className="rounded-xl border bg-white p-6 shadow-sm">
<p className="text-gray-500">
You need to{' '}
<Link href="/settings" className="text-primary-600 underline">
create or join a household
</Link>{' '}
before managing stores.
</p>
<>
<SetPageHeader title="Stores" subtitle="Pharmacies and vendors" />
<div style={{ padding: '28px 32px' }}>
<div
style={{
background: 'var(--bg-elev)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
padding: 24,
}}
>
<p style={{ color: 'var(--ink-muted)', fontSize: 14 }}>
You need to{' '}
<Link href="/settings" style={{ color: 'var(--brand)', textDecoration: 'underline' }}>
create or join a household
</Link>{' '}
before managing stores.
</p>
</div>
</div>
</div>
</>
);
}
return <StoresContent householdId={householdId} />;
return (
<>
<SetPageHeader title="Stores" subtitle="Pharmacies and vendors" />
<div className="mt-page">
<StoresContent householdId={householdId} />
</div>
</>
);
}

View file

@ -1,16 +1,68 @@
import type { Metadata } from 'next';
import { Fraunces, Inter_Tight, JetBrains_Mono } from 'next/font/google';
import { Providers } from '@/components/Providers';
import '@/styles/globals.css';
const interTight = Inter_Tight({
subsets: ['latin'],
variable: '--font-sans',
display: 'swap',
});
const fraunces = Fraunces({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
});
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
});
export const metadata: Metadata = {
title: 'MeshiTrack',
description: 'Medicine & Nutrition Management Platform',
};
// Inline script injected before React hydration to read localStorage and
// apply the saved theme/accent without flash of unstyled content.
const themeScript = `
(function() {
try {
var t = localStorage.getItem('mt-theme') || 'light';
var a = localStorage.getItem('mt-accent') || 'sage';
document.documentElement.setAttribute('data-theme', t);
var accents = {
sage: { brand:'#2f6b4a', deep:'#1e4a32', soft:'#e6efe8', softInk:'#1e4a32' },
cobalt: { brand:'#2e5aa8', deep:'#1d3d75', soft:'#e4eaf5', softInk:'#1d3d75' },
terracotta: { brand:'#b55438', deep:'#7d3825', soft:'#f6e6de', softInk:'#7d3825' },
graphite: { brand:'#2c2c28', deep:'#000000', soft:'#e8e6df', softInk:'#2c2c28' },
};
var ac = accents[a] || accents.sage;
var r = document.documentElement.style;
r.setProperty('--brand', ac.brand);
r.setProperty('--brand-deep', ac.deep);
r.setProperty('--brand-soft', ac.soft);
r.setProperty('--brand-soft-ink', ac.softInk);
r.setProperty('--viz-1', ac.brand);
} catch(e) {}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-50">
<html
lang="en"
className={`${interTight.variable} ${fraunces.variable} ${jetbrainsMono.variable}`}
suppressHydrationWarning
>
{}
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>
<Providers>{children}</Providers>
</body>
</html>

View file

@ -2,7 +2,12 @@
import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';
import { ThemeProvider } from './ThemeProvider';
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
return (
<SessionProvider>
<ThemeProvider>{children}</ThemeProvider>
</SessionProvider>
);
}

View file

@ -0,0 +1,90 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
type Theme = 'light' | 'dark';
type Accent = 'sage' | 'cobalt' | 'terracotta' | 'graphite';
interface AccentTokens {
brand: string;
deep: string;
soft: string;
softInk: string;
}
const ACCENTS: Record<Accent, AccentTokens> = {
sage: { brand: '#2f6b4a', deep: '#1e4a32', soft: '#e6efe8', softInk: '#1e4a32' },
cobalt: { brand: '#2e5aa8', deep: '#1d3d75', soft: '#e4eaf5', softInk: '#1d3d75' },
terracotta: { brand: '#b55438', deep: '#7d3825', soft: '#f6e6de', softInk: '#7d3825' },
graphite: { brand: '#2c2c28', deep: '#000000', soft: '#e8e6df', softInk: '#2c2c28' },
};
interface ThemeContextValue {
theme: Theme;
accent: Accent;
setTheme: (t: Theme) => void;
setAccent: (a: Accent) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
return ctx;
}
function applyAccent(accent: Accent) {
const ac = ACCENTS[accent];
const r = document.documentElement.style;
r.setProperty('--brand', ac.brand);
r.setProperty('--brand-deep', ac.deep);
r.setProperty('--brand-soft', ac.soft);
r.setProperty('--brand-soft-ink', ac.softInk);
r.setProperty('--viz-1', ac.brand);
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>('light');
const [accent, setAccentState] = useState<Accent>('sage');
// Hydrate from localStorage on mount (server renders light/sage defaults).
useEffect(() => {
const savedTheme = (localStorage.getItem('mt-theme') as Theme | null) ?? 'light';
const savedAccent = (localStorage.getItem('mt-accent') as Accent | null) ?? 'sage';
setThemeState(savedTheme);
setAccentState(savedAccent);
}, []);
// Sync theme to DOM + localStorage.
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('mt-theme', theme);
}, [theme]);
// Sync accent to DOM + localStorage.
useEffect(() => {
applyAccent(accent);
localStorage.setItem('mt-accent', accent);
}, [accent]);
function setTheme(t: Theme) {
setThemeState(t);
}
function setAccent(a: Accent) {
setAccentState(a);
}
function toggleTheme() {
setThemeState((prev) => (prev === 'light' ? 'dark' : 'light'));
}
return (
<ThemeContext.Provider value={{ theme, accent, setTheme, setAccent, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}

View file

@ -0,0 +1,40 @@
'use client';
import { createContext, useContext, useState } from 'react';
import type { ReactNode } from 'react';
interface PageHeader {
title: string;
subtitle?: string;
crumbs?: string[];
actions?: ReactNode;
}
interface PageHeaderContextValue {
header: PageHeader;
setHeader: (h: PageHeader) => void;
}
const PageHeaderContext = createContext<PageHeaderContextValue | null>(null);
export function PageHeaderProvider({ children }: { children: ReactNode }) {
const [header, setHeader] = useState<PageHeader>({ title: 'MeshiTrack' });
return (
<PageHeaderContext.Provider value={{ header, setHeader }}>
{children}
</PageHeaderContext.Provider>
);
}
export function usePageHeader(): PageHeaderContextValue {
const ctx = useContext(PageHeaderContext);
// Return a no-op when rendered outside the provider (e.g. in unit tests).
if (!ctx) {
return {
header: { title: '' },
setHeader: () => {},
};
}
return ctx;
}

Some files were not shown because too many files have changed in this diff Show more