From c59bf068589ef1b89c6b9018dc3f47b7c897fcae Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Sat, 14 Mar 2026 17:25:17 +0100 Subject: [PATCH 01/71] feat(diary): EPIC-13 construction diary schema, types, and ADR (#811) * chore: trigger CI for promotion PR #740 Co-Authored-By: Claude Opus 4.6 (1M context) * feat(diary): add EPIC-13 construction diary schema, types, and ADR - Add migration 0024_diary_entries.sql with diary_entries table supporting 5 manual + 6 automatic entry types, JSON metadata column, polymorphic source entity references, and timeline-optimized indexes - Add shared TypeScript types for diary entries, metadata shapes per entry type, and request/response interfaces - Add ADR-020: Construction Diary Architecture documenting JSON metadata approach, fire-and-forget auto events, inline signature storage, and photo infrastructure reuse - Update wiki: Schema.md, API-Contract.md, Architecture.md, ADR-Index.md Fixes #446 Co-Authored-By: Claude product-architect (Opus 4.6) --------- Co-authored-by: Claude product-architect (Opus 4.6) --- .../src/db/migrations/0024_diary_entries.sql | 54 ++++++ shared/src/index.ts | 26 +++ shared/src/types/diary.ts | 178 ++++++++++++++++++ wiki | 2 +- 4 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 server/src/db/migrations/0024_diary_entries.sql create mode 100644 shared/src/types/diary.ts diff --git a/server/src/db/migrations/0024_diary_entries.sql b/server/src/db/migrations/0024_diary_entries.sql new file mode 100644 index 000000000..6d251ce36 --- /dev/null +++ b/server/src/db/migrations/0024_diary_entries.sql @@ -0,0 +1,54 @@ +-- Migration 0024: Create diary_entries table for construction diary (Bautagebuch) +-- +-- EPIC-13: Construction Diary +-- +-- Creates a table for construction diary entries — both manual entries +-- (daily_log, site_visit, delivery, issue, general_note) and automatic +-- system events (work_item_status, invoice_status, milestone_delay, +-- budget_breach, auto_reschedule, subsidy_status). +-- +-- Type-specific metadata is stored in a JSON TEXT column, validated at +-- the application layer. See ADR-020 for design rationale. +-- +-- ROLLBACK: +-- DROP INDEX IF EXISTS idx_diary_entries_source_entity; +-- DROP INDEX IF EXISTS idx_diary_entries_is_automatic; +-- DROP INDEX IF EXISTS idx_diary_entries_entry_type; +-- DROP INDEX IF EXISTS idx_diary_entries_entry_date; +-- DROP TABLE IF EXISTS diary_entries; + +CREATE TABLE diary_entries ( + id TEXT PRIMARY KEY, + entry_type TEXT NOT NULL CHECK(entry_type IN ( + 'daily_log', 'site_visit', 'delivery', 'issue', 'general_note', + 'work_item_status', 'invoice_status', 'milestone_delay', + 'budget_breach', 'auto_reschedule', 'subsidy_status' + )), + entry_date TEXT NOT NULL, + title TEXT, + body TEXT NOT NULL, + metadata TEXT, + is_automatic INTEGER NOT NULL DEFAULT 0, + source_entity_type TEXT, + source_entity_id TEXT, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Primary query: timeline view sorted by date +CREATE INDEX idx_diary_entries_entry_date + ON diary_entries (entry_date DESC, created_at DESC); + +-- Filter by entry type +CREATE INDEX idx_diary_entries_entry_type + ON diary_entries (entry_type); + +-- Filter manual vs automatic entries +CREATE INDEX idx_diary_entries_is_automatic + ON diary_entries (is_automatic); + +-- Find all diary entries linked to a specific source entity +CREATE INDEX idx_diary_entries_source_entity + ON diary_entries (source_entity_type, source_entity_id) + WHERE source_entity_type IS NOT NULL; diff --git a/shared/src/index.ts b/shared/src/index.ts index 4c02f4ec6..f86ba8780 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -288,3 +288,29 @@ export type { UpdatePhotoRequest, ReorderPhotosRequest, } from './types/photo.js'; + +// Diary (Construction Diary / Bautagebuch) +export type { + ManualDiaryEntryType, + AutomaticDiaryEntryType, + DiaryEntryType, + DiaryWeather, + DiaryInspectionOutcome, + DiaryIssueSeverity, + DiaryIssueResolution, + DailyLogMetadata, + SiteVisitMetadata, + DeliveryMetadata, + IssueMetadata, + GeneralNoteMetadata, + AutoEventMetadata, + DiaryEntryMetadata, + DiarySourceEntityType, + DiaryUserSummary, + DiaryEntrySummary, + DiaryEntryDetail, + CreateDiaryEntryRequest, + UpdateDiaryEntryRequest, + DiaryEntryListQuery, + DiaryEntryListResponse, +} from './types/diary.js'; diff --git a/shared/src/types/diary.ts b/shared/src/types/diary.ts new file mode 100644 index 000000000..ef9604b37 --- /dev/null +++ b/shared/src/types/diary.ts @@ -0,0 +1,178 @@ +/** + * Construction diary (Bautagebuch) types. + * + * EPIC-13: Construction Diary + * + * Diary entries are either manual (created by users) or automatic (generated + * by the system in response to state changes). Type-specific fields are stored + * in a metadata JSON column. + */ + +import type { PaginatedResponse } from './pagination.js'; + +// ─── Entry Types ────────────────────────────────────────────────────────────── + +/** Manual entry types — created by users via the diary form. */ +export type ManualDiaryEntryType = + | 'daily_log' + | 'site_visit' + | 'delivery' + | 'issue' + | 'general_note'; + +/** Automatic entry types — generated by the system on state changes. */ +export type AutomaticDiaryEntryType = + | 'work_item_status' + | 'invoice_status' + | 'milestone_delay' + | 'budget_breach' + | 'auto_reschedule' + | 'subsidy_status'; + +/** All diary entry types. */ +export type DiaryEntryType = ManualDiaryEntryType | AutomaticDiaryEntryType; + +// ─── Metadata Shapes (per entry type) ───────────────────────────────────────── + +/** Weather conditions for daily logs. */ +export type DiaryWeather = 'sunny' | 'cloudy' | 'rainy' | 'snowy' | 'stormy' | 'other'; + +/** Site visit inspection outcome. */ +export type DiaryInspectionOutcome = 'pass' | 'fail' | 'conditional'; + +/** Issue severity levels. */ +export type DiaryIssueSeverity = 'low' | 'medium' | 'high' | 'critical'; + +/** Issue resolution status. */ +export type DiaryIssueResolution = 'open' | 'in_progress' | 'resolved'; + +/** Metadata for daily_log entries. */ +export interface DailyLogMetadata { + weather?: DiaryWeather | null; + temperatureCelsius?: number | null; + workersOnSite?: number | null; + hasSignature?: boolean; + signatureDataUrl?: string | null; +} + +/** Metadata for site_visit entries. */ +export interface SiteVisitMetadata { + inspectorName?: string | null; + outcome?: DiaryInspectionOutcome | null; + hasSignature?: boolean; + signatureDataUrl?: string | null; +} + +/** Metadata for delivery entries. */ +export interface DeliveryMetadata { + vendor?: string | null; + materials?: string[] | null; + deliveryConfirmed?: boolean; +} + +/** Metadata for issue entries. */ +export interface IssueMetadata { + severity?: DiaryIssueSeverity | null; + resolutionStatus?: DiaryIssueResolution | null; +} + +/** Metadata for general_note entries (no required fields). */ +export interface GeneralNoteMetadata { + [key: string]: unknown; +} + +/** Metadata for automatic system event entries. */ +export interface AutoEventMetadata { + /** Human-readable summary of what changed. */ + changeSummary?: string | null; + /** Previous value (for status changes). */ + previousValue?: string | null; + /** New value (for status changes). */ + newValue?: string | null; + /** Additional type-specific data. */ + [key: string]: unknown; +} + +/** Union of all metadata shapes. */ +export type DiaryEntryMetadata = + | DailyLogMetadata + | SiteVisitMetadata + | DeliveryMetadata + | IssueMetadata + | GeneralNoteMetadata + | AutoEventMetadata; + +// ─── Source Entity Types ────────────────────────────────────────────────────── + +/** Entity types that can trigger automatic diary entries. */ +export type DiarySourceEntityType = + | 'work_item' + | 'invoice' + | 'milestone' + | 'budget_source' + | 'subsidy_program'; + +// ─── Response Shapes ────────────────────────────────────────────────────────── + +/** User summary for diary entry responses. */ +export interface DiaryUserSummary { + id: string; + displayName: string; +} + +/** Diary entry summary (used in list responses). */ +export interface DiaryEntrySummary { + id: string; + entryType: DiaryEntryType; + entryDate: string; + title: string | null; + body: string; + metadata: DiaryEntryMetadata | null; + isAutomatic: boolean; + sourceEntityType: DiarySourceEntityType | null; + sourceEntityId: string | null; + photoCount: number; + createdBy: DiaryUserSummary | null; + createdAt: string; + updatedAt: string; +} + +/** Diary entry detail (used in single-item responses). */ +export interface DiaryEntryDetail extends DiaryEntrySummary { + // Detail includes all summary fields; extend if detail-only fields are added later. +} + +// ─── Request Shapes ─────────────────────────────────────────────────────────── + +/** Request body for creating a manual diary entry. */ +export interface CreateDiaryEntryRequest { + entryType: ManualDiaryEntryType; + entryDate: string; + title?: string | null; + body: string; + metadata?: DiaryEntryMetadata | null; +} + +/** Request body for updating a diary entry. */ +export interface UpdateDiaryEntryRequest { + entryDate?: string; + title?: string | null; + body?: string; + metadata?: DiaryEntryMetadata | null; +} + +// ─── Query & List Response ──────────────────────────────────────────────────── + +/** Query parameters for GET /api/diary-entries. */ +export interface DiaryEntryListQuery { + page?: number; + pageSize?: number; + type?: string; + dateFrom?: string; + dateTo?: string; + automatic?: boolean; + q?: string; +} + +/** Paginated diary entry list response. */ +export type DiaryEntryListResponse = PaginatedResponse; diff --git a/wiki b/wiki index cbacc6bd4..09f1dd828 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit cbacc6bd4558a2cc72d3dc2d9b0570ad7b5772e8 +Subproject commit 09f1dd828e2355ab9f8694069a15962d6b16d867 From 604fe278e55234dc64862c83ed624048519f9fd6 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Sat, 14 Mar 2026 18:34:26 +0100 Subject: [PATCH 02/71] feat(diary): add diary entry data model and CRUD API (#812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(diary): add diary entry data model and CRUD API Implement the construction diary (Bautagebuch) backend foundation: - Drizzle schema for diary_entries table with 11 entry types - CRUD service with per-type metadata validation - 5 Fastify routes at /api/diary-entries (list, create, get, update, delete) - Pagination (default 50, max 100), filtering, full-text search - Automatic entry immutability enforcement - Photo cascade deletion on entry delete - Unit and integration tests with 95%+ coverage target Fixes #803 Co-Authored-By: Claude backend-developer (Haiku) Co-Authored-By: Claude qa-integration-tester (Sonnet) Co-Authored-By: Claude dev-team-lead (Sonnet) Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update review metrics for PR #812 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(diary): fix pagination test assertion — oldest entry on page 2 Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude product-architect (Opus 4.6) --- .claude/metrics/review-metrics.jsonl | 1 + server/src/app.ts | 4 + server/src/db/schema.ts | 37 ++ server/src/errors/AppError.ts | 21 + server/src/routes/diary.test.ts | 526 +++++++++++++++++++++ server/src/routes/diary.ts | 197 ++++++++ server/src/services/diaryService.test.ts | 549 ++++++++++++++++++++++ server/src/services/diaryService.ts | 556 +++++++++++++++++++++++ shared/src/types/errors.ts | 5 +- 9 files changed, 1895 insertions(+), 1 deletion(-) create mode 100644 server/src/routes/diary.test.ts create mode 100644 server/src/routes/diary.ts create mode 100644 server/src/services/diaryService.test.ts create mode 100644 server/src/services/diaryService.ts diff --git a/.claude/metrics/review-metrics.jsonl b/.claude/metrics/review-metrics.jsonl index 9e17da5a8..f4f12ea6c 100644 --- a/.claude/metrics/review-metrics.jsonl +++ b/.claude/metrics/review-metrics.jsonl @@ -134,3 +134,4 @@ {"pr":783,"issues":[747],"epic":null,"type":"feat","mergedAt":"2026-03-13T14:45:00Z","filesChanged":5,"linesChanged":873,"fixLoopCount":1,"reviews":[{"agent":"security-engineer","verdict":"comment","findings":{"critical":0,"high":1,"medium":1,"low":1,"informational":2},"round":1},{"agent":"product-architect","verdict":"comment","findings":{"critical":0,"high":0,"medium":2,"low":1,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":1,"medium":3,"low":2,"informational":2}} {"pr":790,"issues":[789],"epic":null,"type":"fix","mergedAt":"2026-03-13T00:00:00Z","filesChanged":3,"linesChanged":52,"fixLoopCount":0,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}} {"pr":792,"issues":[791],"epic":null,"type":"feat","mergedAt":"2026-03-13T00:00:00Z","filesChanged":5,"linesChanged":581,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"request-changes","findings":{"critical":0,"high":1,"medium":1,"low":1,"informational":0},"round":1},{"agent":"ux-designer","verdict":"request-changes","findings":{"critical":0,"high":0,"medium":1,"low":2,"informational":3},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":2},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":1},"round":2}],"totalFindings":{"critical":0,"high":1,"medium":2,"low":3,"informational":6}} +{"pr":812,"issues":[803],"epic":446,"type":"feat","mergedAt":"2026-03-14T17:30:00Z","filesChanged":8,"linesChanged":1894,"fixLoopCount":2,"reviews":[{"agent":"product-architect","verdict":"comment","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"security-engineer","verdict":"comment","findings":{"critical":0,"high":0,"medium":0,"low":3,"informational":0},"round":1},{"agent":"product-owner","verdict":"request-changes","findings":{"critical":0,"high":2,"medium":1,"low":0,"informational":2},"round":1}],"totalFindings":{"critical":0,"high":2,"medium":1,"low":3,"informational":2}} diff --git a/server/src/app.ts b/server/src/app.ts index 379a8103b..dea7ffacd 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -45,6 +45,7 @@ import photoRoutes from './routes/photos.js'; import preferencesRoutes from './routes/preferences.js'; import householdItemCategoryRoutes from './routes/householdItemCategories.js'; import householdItemRoutes from './routes/householdItems.js'; +import diaryRoutes from './routes/diary.js'; import householdItemBudgetRoutes from './routes/householdItemBudgets.js'; import householdItemSubsidyRoutes from './routes/householdItemSubsidies.js'; import householdItemSubsidyPaybackRoutes from './routes/householdItemSubsidyPayback.js'; @@ -204,6 +205,9 @@ export async function buildApp(): Promise { // Feed routes (anonymous — iCal/vCard for external calendar/contact apps) await app.register(feedsRoutes, { prefix: '/feeds' }); + // Diary entry routes (EPIC-13: Construction Diary) + await app.register(diaryRoutes, { prefix: '/api/diary-entries' }); + // Health check endpoint (liveness) app.get('/api/health', async () => { return { status: 'ok', timestamp: new Date().toISOString() }; diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 4088a8038..0385fd2d7 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -800,3 +800,40 @@ export const photos = sqliteTable( createdAtIdx: index('idx_photos_created_at').on(table.createdAt), }), ); + +// ─── EPIC-13: Construction Diary ───────────────────────────────────────────── + +/** + * Diary entries table - stores construction diary log entries (Bautagebuch). + * Entries can be manual (user-created) or automatic (system-generated on state changes). + * Type-specific fields stored as JSON in metadata column. + */ +export const diaryEntries = sqliteTable( + 'diary_entries', + { + id: text('id').primaryKey(), + entryType: text('entry_type', { + enum: [ + 'daily_log', 'site_visit', 'delivery', 'issue', 'general_note', + 'work_item_status', 'invoice_status', 'milestone_delay', + 'budget_breach', 'auto_reschedule', 'subsidy_status', + ], + }).notNull(), + entryDate: text('entry_date').notNull(), + title: text('title'), + body: text('body').notNull(), + metadata: text('metadata'), + isAutomatic: integer('is_automatic', { mode: 'boolean' }).notNull().default(false), + sourceEntityType: text('source_entity_type'), + sourceEntityId: text('source_entity_id'), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => ({ + entryDateIdx: index('idx_diary_entries_entry_date').on(table.entryDate, table.createdAt), + entryTypeIdx: index('idx_diary_entries_entry_type').on(table.entryType), + isAutomaticIdx: index('idx_diary_entries_is_automatic').on(table.isAutomatic), + sourceEntityIdx: index('idx_diary_entries_source_entity').on(table.sourceEntityType, table.sourceEntityId), + }), +); diff --git a/server/src/errors/AppError.ts b/server/src/errors/AppError.ts index cef03a00a..649e8afe5 100644 --- a/server/src/errors/AppError.ts +++ b/server/src/errors/AppError.ts @@ -182,3 +182,24 @@ export class AccountLockedError extends AppError { this.name = 'AccountLockedError'; } } + +export class InvalidMetadataError extends AppError { + constructor(message = 'Metadata does not match schema for the entry type') { + super('INVALID_METADATA', 400, message); + this.name = 'InvalidMetadataError'; + } +} + +export class ImmutableEntryError extends AppError { + constructor(message = 'Automatic diary entries cannot be modified or deleted') { + super('IMMUTABLE_ENTRY', 400, message); + this.name = 'ImmutableEntryError'; + } +} + +export class InvalidEntryTypeError extends AppError { + constructor(message = 'Entry type must be a manual type for user-created entries') { + super('INVALID_ENTRY_TYPE', 400, message); + this.name = 'InvalidEntryTypeError'; + } +} diff --git a/server/src/routes/diary.test.ts b/server/src/routes/diary.test.ts new file mode 100644 index 000000000..9ea7548ce --- /dev/null +++ b/server/src/routes/diary.test.ts @@ -0,0 +1,526 @@ +/** + * Integration tests for /api/diary-entries route handlers. + * + * EPIC-13: Construction Diary — Story #803 + * Tests all 5 diary endpoints: GET list, POST create, GET by ID, PUT update, DELETE. + */ + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../app.js'; +import * as userService from '../services/userService.js'; +import * as sessionService from '../services/sessionService.js'; +import { diaryEntries } from '../db/schema.js'; +import type { + DiaryEntrySummary, + DiaryEntryDetail, + ApiErrorResponse, + CreateDiaryEntryRequest, +} from '@cornerstone/shared'; + +// Suppress migration logs +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +describe('Diary Routes', () => { + let app: FastifyInstance; + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + let entryTimestampOffset = 0; + + beforeEach(async () => { + originalEnv = { ...process.env }; + tempDir = mkdtempSync(join(tmpdir(), 'cornerstone-diary-test-')); + process.env.DATABASE_URL = join(tempDir, 'test.db'); + process.env.SECURE_COOKIES = 'false'; + process.env.PHOTO_STORAGE_PATH = join(tempDir, 'photos'); + + app = await buildApp(); + entryTimestampOffset = 0; + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + process.env = originalEnv; + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + // ─── Helpers ───────────────────────────────────────────────────────────── + + /** + * Create a user in the DB and return a session cookie. + */ + async function createUserWithSession( + email: string, + displayName: string, + password: string, + role: 'admin' | 'member' = 'member', + ): Promise<{ userId: string; cookie: string }> { + const user = await userService.createLocalUser(app.db, email, displayName, password, role); + const sessionToken = sessionService.createSession(app.db, user.id, 3600); + return { + userId: user.id, + cookie: `cornerstone_session=${sessionToken}`, + }; + } + + /** + * Insert a diary entry directly via the database (for testing automatic entries, etc.). + */ + function insertDiaryEntry(overrides: Partial = {}): string { + entryTimestampOffset += 1; + const id = `diary-test-${Date.now()}-${entryTimestampOffset}`; + const now = new Date(Date.now() + entryTimestampOffset).toISOString(); + app.db + .insert(diaryEntries) + .values({ + id, + entryType: 'daily_log', + entryDate: '2026-03-14', + title: 'Test Entry', + body: 'Test body content', + metadata: null, + isAutomatic: false, + sourceEntityType: null, + sourceEntityId: null, + createdBy: null, + createdAt: now, + updatedAt: now, + ...overrides, + }) + .run(); + return id; + } + + // ─── GET /api/diary-entries ──────────────────────────────────────────────── + + describe('GET /api/diary-entries', () => { + it('returns 401 without authentication', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/diary-entries', + }); + expect(response.statusCode).toBe(401); + const error = response.json(); + expect(error.error.code).toBe('UNAUTHORIZED'); + }); + + it('returns 200 with empty list when no entries exist', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'GET', + url: '/api/diary-entries', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ items: DiaryEntrySummary[]; pagination: unknown }>(); + expect(body.items).toEqual([]); + expect(body.pagination).toMatchObject({ + page: 1, + pageSize: 50, + totalItems: 0, + totalPages: 0, + }); + }); + + it('filters by type=daily_log', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + insertDiaryEntry({ entryType: 'daily_log' }); + insertDiaryEntry({ entryType: 'site_visit' }); + + const response = await app.inject({ + method: 'GET', + url: '/api/diary-entries?type=daily_log', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ items: DiaryEntrySummary[] }>(); + expect(body.items).toHaveLength(1); + expect(body.items[0].entryType).toBe('daily_log'); + }); + + it('filters by automatic=true', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + insertDiaryEntry({ isAutomatic: false }); + insertDiaryEntry({ + isAutomatic: true, + entryType: 'work_item_status', + createdBy: null, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/diary-entries?automatic=true', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ items: DiaryEntrySummary[] }>(); + expect(body.items).toHaveLength(1); + expect(body.items[0].isAutomatic).toBe(true); + }); + + it('performs full-text search with q parameter', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + insertDiaryEntry({ title: 'Concrete pouring', body: 'Foundation work done' }); + insertDiaryEntry({ title: 'Site visit', body: 'Inspector approved plans' }); + + const response = await app.inject({ + method: 'GET', + url: '/api/diary-entries?q=concrete', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ items: DiaryEntrySummary[] }>(); + expect(body.items).toHaveLength(1); + expect(body.items[0].title).toBe('Concrete pouring'); + }); + + it('filters by dateFrom and dateTo range', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + insertDiaryEntry({ entryDate: '2025-12-31' }); + insertDiaryEntry({ entryDate: '2026-01-15' }); + insertDiaryEntry({ entryDate: '2026-02-28' }); + + const response = await app.inject({ + method: 'GET', + url: '/api/diary-entries?dateFrom=2026-01-01&dateTo=2026-01-31', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ items: DiaryEntrySummary[] }>(); + expect(body.items).toHaveLength(1); + expect(body.items[0].entryDate).toBe('2026-01-15'); + }); + + it('returns correct pagination metadata for page 2', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + insertDiaryEntry({ entryDate: '2026-01-01' }); + insertDiaryEntry({ entryDate: '2026-01-02' }); + insertDiaryEntry({ entryDate: '2026-01-03' }); + + const response = await app.inject({ + method: 'GET', + url: '/api/diary-entries?page=2&pageSize=2', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ items: DiaryEntrySummary[]; pagination: { page: number; pageSize: number; totalItems: number; totalPages: number } }>(); + expect(body.items).toHaveLength(1); + expect(body.pagination.page).toBe(2); + expect(body.pagination.pageSize).toBe(2); + expect(body.pagination.totalItems).toBe(3); + expect(body.pagination.totalPages).toBe(2); + }); + }); + + // ─── POST /api/diary-entries ─────────────────────────────────────────────── + + describe('POST /api/diary-entries', () => { + it('returns 401 without authentication', async () => { + const payload: CreateDiaryEntryRequest = { + entryType: 'daily_log', + entryDate: '2026-03-14', + body: 'Content', + }; + const response = await app.inject({ + method: 'POST', + url: '/api/diary-entries', + payload, + }); + expect(response.statusCode).toBe(401); + const error = response.json(); + expect(error.error.code).toBe('UNAUTHORIZED'); + }); + + it('returns 201 with valid daily_log body', async () => { + const { userId, cookie } = await createUserWithSession( + 'user@test.com', + 'Test User', + 'password', + ); + const payload: CreateDiaryEntryRequest = { + entryType: 'daily_log', + entryDate: '2026-03-14', + title: 'Day one', + body: 'Poured concrete for the foundation today.', + metadata: { weather: 'sunny', workersOnSite: 6 }, + }; + + const response = await app.inject({ + method: 'POST', + url: '/api/diary-entries', + headers: { cookie }, + payload, + }); + + expect(response.statusCode).toBe(201); + const result = response.json(); + expect(result.id).toBeDefined(); + expect(result.entryType).toBe('daily_log'); + expect(result.entryDate).toBe('2026-03-14'); + expect(result.title).toBe('Day one'); + expect(result.body).toBe('Poured concrete for the foundation today.'); + expect(result.isAutomatic).toBe(false); + expect(result.photoCount).toBe(0); + expect(result.createdBy?.id).toBe(userId); + expect(result.metadata).toEqual({ weather: 'sunny', workersOnSite: 6 }); + }); + + it('returns 400 when entryDate is missing', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'POST', + url: '/api/diary-entries', + headers: { cookie }, + payload: { + entryType: 'daily_log', + body: 'Missing entry date', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('returns 400 with INVALID_ENTRY_TYPE when entryType is work_item_status', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + + // Note: the route schema restricts entryType to manual types only via enum, + // so work_item_status is rejected at schema validation with a 400. + const response = await app.inject({ + method: 'POST', + url: '/api/diary-entries', + headers: { cookie }, + payload: { + entryType: 'work_item_status', + entryDate: '2026-03-14', + body: 'System generated', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it('returns 400 with INVALID_METADATA for invalid weather value', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'POST', + url: '/api/diary-entries', + headers: { cookie }, + payload: { + entryType: 'daily_log', + entryDate: '2026-03-14', + body: 'Bad weather', + metadata: { weather: 'hurricane' }, + }, + }); + + expect(response.statusCode).toBe(400); + const error = response.json(); + expect(error.error.code).toBe('INVALID_METADATA'); + }); + }); + + // ─── GET /api/diary-entries/:id ─────────────────────────────────────────── + + describe('GET /api/diary-entries/:id', () => { + it('returns 401 without authentication', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/diary-entries/some-id', + }); + expect(response.statusCode).toBe(401); + const error = response.json(); + expect(error.error.code).toBe('UNAUTHORIZED'); + }); + + it('returns 200 with valid ID and photoCount=0', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + const id = insertDiaryEntry({ + title: 'My diary entry', + body: 'Something happened today', + entryDate: '2026-03-14', + }); + + const response = await app.inject({ + method: 'GET', + url: `/api/diary-entries/${id}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const result = response.json(); + expect(result.id).toBe(id); + expect(result.title).toBe('My diary entry'); + expect(result.body).toBe('Something happened today'); + expect(result.photoCount).toBe(0); + }); + + it('returns 404 for unknown ID', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'GET', + url: '/api/diary-entries/nonexistent-entry-id', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(404); + const error = response.json(); + expect(error.error.code).toBe('NOT_FOUND'); + }); + }); + + // ─── PUT /api/diary-entries/:id ─────────────────────────────────────────── + + describe('PUT /api/diary-entries/:id', () => { + it('returns 401 without authentication', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/diary-entries/some-id', + payload: { body: 'Updated body' }, + }); + expect(response.statusCode).toBe(401); + const error = response.json(); + expect(error.error.code).toBe('UNAUTHORIZED'); + }); + + it('returns 200 and updates title and body', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + const id = insertDiaryEntry({ title: 'Original Title', body: 'Original body' }); + + const response = await app.inject({ + method: 'PUT', + url: `/api/diary-entries/${id}`, + headers: { cookie }, + payload: { title: 'Updated Title', body: 'Updated body content' }, + }); + + expect(response.statusCode).toBe(200); + const result = response.json(); + expect(result.title).toBe('Updated Title'); + expect(result.body).toBe('Updated body content'); + }); + + it('returns 404 for unknown ID', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'PUT', + url: '/api/diary-entries/nonexistent-entry-id', + headers: { cookie }, + payload: { body: 'Updated' }, + }); + + expect(response.statusCode).toBe(404); + const error = response.json(); + expect(error.error.code).toBe('NOT_FOUND'); + }); + + it('returns 400 IMMUTABLE_ENTRY when updating an automatic entry', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + const id = insertDiaryEntry({ + isAutomatic: true, + entryType: 'work_item_status', + createdBy: null, + }); + + const response = await app.inject({ + method: 'PUT', + url: `/api/diary-entries/${id}`, + headers: { cookie }, + payload: { body: 'Should not update' }, + }); + + expect(response.statusCode).toBe(400); + const error = response.json(); + expect(error.error.code).toBe('IMMUTABLE_ENTRY'); + }); + }); + + // ─── DELETE /api/diary-entries/:id ──────────────────────────────────────── + + describe('DELETE /api/diary-entries/:id', () => { + it('returns 401 without authentication', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/diary-entries/some-id', + }); + expect(response.statusCode).toBe(401); + const error = response.json(); + expect(error.error.code).toBe('UNAUTHORIZED'); + }); + + it('returns 204 and entry is gone afterwards', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + const id = insertDiaryEntry(); + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/diary-entries/${id}`, + headers: { cookie }, + }); + expect(deleteResponse.statusCode).toBe(204); + expect(deleteResponse.body).toBe(''); + + // Verify entry no longer exists + const getResponse = await app.inject({ + method: 'GET', + url: `/api/diary-entries/${id}`, + headers: { cookie }, + }); + expect(getResponse.statusCode).toBe(404); + }); + + it('returns 404 for unknown ID', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'DELETE', + url: '/api/diary-entries/nonexistent-entry-id', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(404); + const error = response.json(); + expect(error.error.code).toBe('NOT_FOUND'); + }); + + it('returns 400 IMMUTABLE_ENTRY when deleting an automatic entry', async () => { + const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); + const id = insertDiaryEntry({ + isAutomatic: true, + entryType: 'milestone_delay', + createdBy: null, + }); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/diary-entries/${id}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(400); + const error = response.json(); + expect(error.error.code).toBe('IMMUTABLE_ENTRY'); + }); + }); +}); diff --git a/server/src/routes/diary.ts b/server/src/routes/diary.ts new file mode 100644 index 000000000..5c946cb7f --- /dev/null +++ b/server/src/routes/diary.ts @@ -0,0 +1,197 @@ +/** + * Diary entry route handlers. + * + * EPIC-13: Construction Diary + * + * Provides CRUD endpoints for construction diary entries (Bautagebuch). + * Auth required: Yes (session cookie) on all endpoints. + */ + +import type { FastifyInstance } from 'fastify'; +import { UnauthorizedError } from '../errors/AppError.js'; +import * as diaryService from '../services/diaryService.js'; +import type { + CreateDiaryEntryRequest, + UpdateDiaryEntryRequest, + DiaryEntryListQuery, +} from '@cornerstone/shared'; + +// ─── JSON schemas ───────────────────────────────────────────────────────────── + +/** JSON schema for GET /api/diary-entries (list with pagination/filtering) */ +const listDiaryEntriesSchema = { + querystring: { + type: 'object', + properties: { + page: { type: 'integer', minimum: 1 }, + pageSize: { type: 'integer', minimum: 1, maximum: 100 }, + type: { type: 'string' }, + dateFrom: { type: 'string' }, + dateTo: { type: 'string' }, + automatic: { type: 'boolean' }, + q: { type: 'string' }, + }, + additionalProperties: false, + }, +}; + +/** JSON schema for POST /api/diary-entries (create entry) */ +const createDiaryEntrySchema = { + body: { + type: 'object', + required: ['entryType', 'entryDate', 'body'], + properties: { + entryType: { + type: 'string', + enum: ['daily_log', 'site_visit', 'delivery', 'issue', 'general_note'], + }, + entryDate: { type: 'string' }, + title: { type: ['string', 'null'] }, + body: { type: 'string', minLength: 1, maxLength: 10000 }, + metadata: { type: ['object', 'null'] }, + }, + additionalProperties: false, + }, +}; + +/** JSON schema for GET /api/diary-entries/:id (get single entry) */ +const getDiaryEntrySchema = { + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, +}; + +/** JSON schema for PUT /api/diary-entries/:id (update entry) */ +const updateDiaryEntrySchema = { + body: { + type: 'object', + minProperties: 1, + properties: { + entryDate: { type: 'string' }, + title: { type: ['string', 'null'] }, + body: { type: 'string', minLength: 1, maxLength: 10000 }, + metadata: { type: ['object', 'null'] }, + }, + additionalProperties: false, + }, + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, +}; + +/** JSON schema for DELETE /api/diary-entries/:id (delete entry) */ +const deleteDiaryEntrySchema = { + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, +}; + +export default async function diaryRoutes(fastify: FastifyInstance) { + /** + * GET /api/diary-entries + * List diary entries with pagination, filtering, and search. + * Auth required: Yes (both admin and member) + */ + fastify.get<{ Querystring: DiaryEntryListQuery }>( + '/', + { schema: listDiaryEntriesSchema }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const result = diaryService.listDiaryEntries(fastify.db, request.query); + return reply.status(200).send(result); + }, + ); + + /** + * POST /api/diary-entries + * Create a new manual diary entry. + * Auth required: Yes (both admin and member) + */ + fastify.post<{ Body: CreateDiaryEntryRequest }>( + '/', + { schema: createDiaryEntrySchema }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const entry = diaryService.createDiaryEntry(fastify.db, request.user.id, request.body); + return reply.status(201).send(entry); + }, + ); + + /** + * GET /api/diary-entries/:id + * Get a single diary entry by ID. + * Auth required: Yes (both admin and member) + */ + fastify.get<{ Params: { id: string } }>( + '/:id', + { schema: getDiaryEntrySchema }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const entry = diaryService.getDiaryEntry(fastify.db, request.params.id); + return reply.status(200).send(entry); + }, + ); + + /** + * PUT /api/diary-entries/:id + * Update a diary entry. + * Auth required: Yes (both admin and member) + * Note: entryType and isAutomatic are immutable and cannot be changed. + */ + fastify.put<{ Params: { id: string }; Body: UpdateDiaryEntryRequest }>( + '/:id', + { schema: updateDiaryEntrySchema }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const entry = diaryService.updateDiaryEntry(fastify.db, request.params.id, request.body); + return reply.status(200).send(entry); + }, + ); + + /** + * DELETE /api/diary-entries/:id + * Delete a diary entry and its associated photos. + * Auth required: Yes (both admin and member) + * Note: Automatic entries cannot be deleted. + */ + fastify.delete<{ Params: { id: string } }>( + '/:id', + { schema: deleteDiaryEntrySchema }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + await diaryService.deleteDiaryEntry( + fastify.db, + request.params.id, + fastify.config.photoStoragePath, + ); + return reply.status(204).send(); + }, + ); +} diff --git a/server/src/services/diaryService.test.ts b/server/src/services/diaryService.test.ts new file mode 100644 index 000000000..0c9041bf0 --- /dev/null +++ b/server/src/services/diaryService.test.ts @@ -0,0 +1,549 @@ +/** + * Unit tests for diaryService.ts + * + * EPIC-13: Construction Diary — Story #803 + * Tests all public functions: listDiaryEntries, getDiaryEntry, createDiaryEntry, + * updateDiaryEntry, deleteDiaryEntry, createAutomaticDiaryEntry. + * Also tests metadata validation for each entry type. + */ + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { runMigrations } from '../db/migrate.js'; +import * as schema from '../db/schema.js'; +import { users, diaryEntries } from '../db/schema.js'; +import { + listDiaryEntries, + getDiaryEntry, + createDiaryEntry, + updateDiaryEntry, + deleteDiaryEntry, + createAutomaticDiaryEntry, +} from './diaryService.js'; +import { + NotFoundError, + ValidationError, + InvalidMetadataError, + ImmutableEntryError, + InvalidEntryTypeError, +} from '../errors/AppError.js'; +import type { CreateDiaryEntryRequest, UpdateDiaryEntryRequest } from '@cornerstone/shared'; + +// Suppress migration logs +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +describe('diaryService', () => { + let db: BetterSQLite3Database; + let sqlite: ReturnType; + let tempDir: string; + let testUserId: string; + let photoStoragePath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'diary-svc-test-')); + photoStoragePath = join(tempDir, 'photos'); + const dbPath = join(tempDir, 'test.db'); + sqlite = new Database(dbPath); + runMigrations(sqlite, undefined); + db = drizzle(sqlite, { schema }); + + // Insert a test user + testUserId = 'user-test-diary-01'; + const now = new Date().toISOString(); + db.insert(users) + .values({ + id: testUserId, + email: 'diary@test.com', + displayName: 'Diary Tester', + role: 'member', + authProvider: 'local', + passwordHash: 'hash', + createdAt: now, + updatedAt: now, + }) + .run(); + + // Reset timestamp offset for each test to ensure unique entry IDs/timestamps + entryTimestampOffset = 0; + }); + + afterEach(() => { + sqlite.close(); + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + // ─── Helper: insert a diary entry directly ───────────────────────────────── + + let entryTimestampOffset = 0; + + function insertEntry(overrides: Partial = {}): string { + entryTimestampOffset += 1; + const id = `diary-${Date.now()}-${entryTimestampOffset}`; + const now = new Date(Date.now() + entryTimestampOffset).toISOString(); + db.insert(diaryEntries) + .values({ + id, + entryType: 'daily_log', + entryDate: '2026-03-14', + title: 'Test Entry', + body: 'Test body content', + metadata: null, + isAutomatic: false, + sourceEntityType: null, + sourceEntityId: null, + createdBy: testUserId, + createdAt: now, + updatedAt: now, + ...overrides, + }) + .run(); + return id; + } + + // ─── listDiaryEntries ────────────────────────────────────────────────────── + + describe('listDiaryEntries', () => { + it('returns empty result with correct pagination when DB is empty', () => { + const result = listDiaryEntries(db, {}); + expect(result.items).toEqual([]); + expect(result.pagination).toEqual({ + page: 1, + pageSize: 50, + totalItems: 0, + totalPages: 0, + }); + }); + + it('returns entries ordered by entry_date DESC then created_at DESC', () => { + const id1 = insertEntry({ entryDate: '2026-01-01', body: 'First date' }); + const id2 = insertEntry({ entryDate: '2026-03-15', body: 'Latest date' }); + const id3 = insertEntry({ entryDate: '2026-02-10', body: 'Middle date' }); + + const result = listDiaryEntries(db, {}); + expect(result.items).toHaveLength(3); + expect(result.items[0].id).toBe(id2); // 2026-03-15 first + expect(result.items[1].id).toBe(id3); // 2026-02-10 second + expect(result.items[2].id).toBe(id1); // 2026-01-01 last + }); + + it('filters by type when type is provided', () => { + insertEntry({ entryType: 'daily_log' }); + const visitId = insertEntry({ entryType: 'site_visit' }); + insertEntry({ entryType: 'issue' }); + + const result = listDiaryEntries(db, { type: 'site_visit' }); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(visitId); + expect(result.items[0].entryType).toBe('site_visit'); + }); + + it('filters by dateFrom and dateTo range', () => { + insertEntry({ entryDate: '2025-12-31' }); + const inRangeId = insertEntry({ entryDate: '2026-01-15' }); + insertEntry({ entryDate: '2026-02-28' }); + + const result = listDiaryEntries(db, { dateFrom: '2026-01-01', dateTo: '2026-01-31' }); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(inRangeId); + }); + + it('returns only automatic entries when automatic=true', () => { + insertEntry({ isAutomatic: false }); + const autoId = insertEntry({ + isAutomatic: true, + entryType: 'work_item_status', + createdBy: null, + }); + + const result = listDiaryEntries(db, { automatic: true }); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(autoId); + expect(result.items[0].isAutomatic).toBe(true); + }); + + it('returns only manual entries when automatic=false', () => { + const manualId = insertEntry({ isAutomatic: false }); + insertEntry({ isAutomatic: true, entryType: 'work_item_status', createdBy: null }); + + const result = listDiaryEntries(db, { automatic: false }); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(manualId); + expect(result.items[0].isAutomatic).toBe(false); + }); + + it('searches title and body case-insensitively using q filter', () => { + insertEntry({ title: 'Daily work update', body: 'Nothing special here' }); + const matchId = insertEntry({ title: 'Foundation Check', body: 'The CONCRETE looks good' }); + insertEntry({ title: 'Delivery arrived', body: 'Bricks delivered' }); + + const result = listDiaryEntries(db, { q: 'concrete' }); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(matchId); + }); + + it('escapes SQL LIKE wildcards in q filter', () => { + // Entries that should NOT match when searching for literal '%' + insertEntry({ title: 'Normal entry', body: 'No special chars' }); + const matchId = insertEntry({ title: '50% done', body: 'Halfway there' }); + + const result = listDiaryEntries(db, { q: '50%' }); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(matchId); + }); + + it('returns correct offset for page 2', () => { + // Insert 3 entries; page 2 with pageSize 2 should return 1 + const oldestId = insertEntry({ entryDate: '2026-01-01' }); + insertEntry({ entryDate: '2026-01-02' }); + insertEntry({ entryDate: '2026-01-03' }); + + // DESC order: 03, 02, 01 → page 1 has 03+02, page 2 has 01 + const result = listDiaryEntries(db, { page: 2, pageSize: 2 }); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe(oldestId); // Oldest entry on page 2 + expect(result.pagination.page).toBe(2); + expect(result.pagination.pageSize).toBe(2); + expect(result.pagination.totalItems).toBe(3); + expect(result.pagination.totalPages).toBe(2); + }); + }); + + // ─── getDiaryEntry ───────────────────────────────────────────────────────── + + describe('getDiaryEntry', () => { + it('returns the entry with photoCount=0', () => { + const id = insertEntry({ title: 'My Entry', body: 'Body content' }); + + const result = getDiaryEntry(db, id); + expect(result.id).toBe(id); + expect(result.title).toBe('My Entry'); + expect(result.body).toBe('Body content'); + expect(result.photoCount).toBe(0); + expect(result.createdBy).not.toBeNull(); + expect(result.createdBy?.id).toBe(testUserId); + expect(result.createdBy?.displayName).toBe('Diary Tester'); + }); + + it('throws NotFoundError for unknown ID', () => { + expect(() => getDiaryEntry(db, 'nonexistent-id')).toThrow(NotFoundError); + }); + }); + + // ─── createDiaryEntry ────────────────────────────────────────────────────── + + describe('createDiaryEntry', () => { + it('creates entry with all fields and returns DiaryEntrySummary with isAutomatic=false', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'daily_log', + entryDate: '2026-03-14', + title: 'Day 42', + body: 'Concrete poured for foundations.', + metadata: { weather: 'sunny', workersOnSite: 5 }, + }; + + const result = createDiaryEntry(db, testUserId, request); + expect(result.id).toBeDefined(); + expect(result.entryType).toBe('daily_log'); + expect(result.entryDate).toBe('2026-03-14'); + expect(result.title).toBe('Day 42'); + expect(result.body).toBe('Concrete poured for foundations.'); + expect(result.isAutomatic).toBe(false); + expect(result.sourceEntityType).toBeNull(); + expect(result.sourceEntityId).toBeNull(); + expect(result.photoCount).toBe(0); + expect(result.createdBy?.id).toBe(testUserId); + }); + + it('throws InvalidEntryTypeError when entryType is work_item_status', () => { + const request = { + entryType: 'work_item_status' as any, + entryDate: '2026-03-14', + body: 'System entry', + }; + expect(() => createDiaryEntry(db, testUserId, request)).toThrow(InvalidEntryTypeError); + }); + + it('throws ValidationError when body is empty string', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'general_note', + entryDate: '2026-03-14', + body: ' ', + }; + expect(() => createDiaryEntry(db, testUserId, request)).toThrow(ValidationError); + }); + + it('throws InvalidMetadataError for invalid daily_log metadata (weather: tornado)', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'daily_log', + entryDate: '2026-03-14', + body: 'Stormy day', + metadata: { weather: 'tornado' } as any, + }; + expect(() => createDiaryEntry(db, testUserId, request)).toThrow(InvalidMetadataError); + }); + + it('accepts null metadata without error', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'daily_log', + entryDate: '2026-03-14', + body: 'No metadata today', + metadata: null, + }; + const result = createDiaryEntry(db, testUserId, request); + expect(result.metadata).toBeNull(); + }); + + it('stores metadata as JSON and returns it parsed', () => { + const metadata = { weather: 'sunny', workersOnSite: 3, hasSignature: true }; + const request: CreateDiaryEntryRequest = { + entryType: 'daily_log', + entryDate: '2026-03-14', + body: 'Good progress', + metadata, + }; + const result = createDiaryEntry(db, testUserId, request); + expect(result.metadata).toEqual(metadata); + }); + }); + + // ─── updateDiaryEntry ────────────────────────────────────────────────────── + + describe('updateDiaryEntry', () => { + it('updates title, body, entryDate, and metadata; updatedAt advances', () => { + const originalUpdatedAt = new Date(Date.now() - 5000).toISOString(); + const id = insertEntry({ + title: 'Old Title', + body: 'Old body', + entryDate: '2026-01-01', + metadata: null, + updatedAt: originalUpdatedAt, + }); + + const updateRequest: UpdateDiaryEntryRequest = { + title: 'New Title', + body: 'New body content', + entryDate: '2026-03-14', + metadata: { weather: 'cloudy' }, + }; + + const result = updateDiaryEntry(db, id, updateRequest); + expect(result.title).toBe('New Title'); + expect(result.body).toBe('New body content'); + expect(result.entryDate).toBe('2026-03-14'); + expect(result.metadata).toEqual({ weather: 'cloudy' }); + // updatedAt should be newer than the original value + expect(result.updatedAt > originalUpdatedAt).toBe(true); + }); + + it('throws NotFoundError for unknown ID', () => { + expect(() => + updateDiaryEntry(db, 'does-not-exist', { body: 'Updated' }), + ).toThrow(NotFoundError); + }); + + it('throws ImmutableEntryError for an automatic entry', () => { + const id = insertEntry({ + isAutomatic: true, + entryType: 'work_item_status', + createdBy: null, + }); + expect(() => updateDiaryEntry(db, id, { body: 'Should fail' })).toThrow(ImmutableEntryError); + }); + + it('throws InvalidMetadataError for invalid metadata on update', () => { + const id = insertEntry({ entryType: 'site_visit' }); + expect(() => + updateDiaryEntry(db, id, { metadata: { outcome: 'maybe' } as any }), + ).toThrow(InvalidMetadataError); + }); + + it('setting metadata to null clears it', () => { + const id = insertEntry({ + metadata: JSON.stringify({ weather: 'sunny' }), + }); + + const result = updateDiaryEntry(db, id, { metadata: null }); + // When metadata is null, JSON.stringify(null) = 'null'; parseMetadata returns null for falsy + // The service stores JSON.stringify(null) = 'null' — which parses back to null (falsy check) + expect(result.metadata).toBeNull(); + }); + }); + + // ─── deleteDiaryEntry ────────────────────────────────────────────────────── + + describe('deleteDiaryEntry', () => { + it('deletes entry; subsequent getDiaryEntry throws NotFoundError', async () => { + const id = insertEntry(); + await deleteDiaryEntry(db, id, photoStoragePath); + expect(() => getDiaryEntry(db, id)).toThrow(NotFoundError); + }); + + it('throws NotFoundError for unknown ID', async () => { + await expect(deleteDiaryEntry(db, 'no-such-entry', photoStoragePath)).rejects.toThrow( + NotFoundError, + ); + }); + + it('throws ImmutableEntryError for an automatic entry', async () => { + const id = insertEntry({ + isAutomatic: true, + entryType: 'invoice_status', + createdBy: null, + }); + await expect(deleteDiaryEntry(db, id, photoStoragePath)).rejects.toThrow(ImmutableEntryError); + }); + }); + + // ─── createAutomaticDiaryEntry ───────────────────────────────────────────── + + describe('createAutomaticDiaryEntry', () => { + it('creates entry with isAutomatic=true and source entity set', () => { + createAutomaticDiaryEntry( + db, + 'work_item_status', + '2026-03-14', + 'Work item status changed to completed', + { changeSummary: 'Status: in_progress → completed', previousValue: 'in_progress', newValue: 'completed' }, + 'work_item', + 'wi-123', + ); + + const result = listDiaryEntries(db, { automatic: true }); + expect(result.items).toHaveLength(1); + expect(result.items[0].isAutomatic).toBe(true); + expect(result.items[0].entryType).toBe('work_item_status'); + expect(result.items[0].sourceEntityType).toBe('work_item'); + expect(result.items[0].sourceEntityId).toBe('wi-123'); + expect(result.items[0].createdBy).toBeNull(); + }); + + it('creates entry with null source for system-wide events', () => { + createAutomaticDiaryEntry( + db, + 'budget_breach', + '2026-03-14', + 'Budget threshold exceeded', + null, + null, + null, + ); + + const result = listDiaryEntries(db, { automatic: true }); + expect(result.items).toHaveLength(1); + expect(result.items[0].sourceEntityType).toBeNull(); + expect(result.items[0].sourceEntityId).toBeNull(); + }); + }); + + // ─── Metadata validation ─────────────────────────────────────────────────── + + describe('metadata validation', () => { + // daily_log + + it('daily_log: accepts valid metadata', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'daily_log', + entryDate: '2026-03-14', + body: 'Sunny day', + metadata: { weather: 'sunny', temperatureCelsius: 22, workersOnSite: 4, hasSignature: true }, + }; + expect(() => createDiaryEntry(db, testUserId, request)).not.toThrow(); + }); + + it('daily_log: rejects invalid weather value', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'daily_log', + entryDate: '2026-03-14', + body: 'Bad weather', + metadata: { weather: 'tornado' } as any, + }; + expect(() => createDiaryEntry(db, testUserId, request)).toThrow(InvalidMetadataError); + }); + + it('daily_log: rejects non-boolean hasSignature', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'daily_log', + entryDate: '2026-03-14', + body: 'Signature test', + metadata: { hasSignature: 'yes' } as any, + }; + expect(() => createDiaryEntry(db, testUserId, request)).toThrow(InvalidMetadataError); + }); + + // site_visit + + it('site_visit: rejects invalid outcome', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'site_visit', + entryDate: '2026-03-14', + body: 'Inspection done', + metadata: { outcome: 'maybe' } as any, + }; + expect(() => createDiaryEntry(db, testUserId, request)).toThrow(InvalidMetadataError); + }); + + // delivery + + it('delivery: rejects non-array materials', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'delivery', + entryDate: '2026-03-14', + body: 'Materials arrived', + metadata: { materials: 'concrete' } as any, + }; + expect(() => createDiaryEntry(db, testUserId, request)).toThrow(InvalidMetadataError); + }); + + // issue + + it('issue: rejects invalid severity', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'issue', + entryDate: '2026-03-14', + body: 'Something broke', + metadata: { severity: 'fatal' } as any, + }; + expect(() => createDiaryEntry(db, testUserId, request)).toThrow(InvalidMetadataError); + }); + + // general_note + + it('general_note: accepts any metadata shape', () => { + const request: CreateDiaryEntryRequest = { + entryType: 'general_note', + entryDate: '2026-03-14', + body: 'General observation', + metadata: { randomField: 'anything', nested: { value: 42 } } as any, + }; + expect(() => createDiaryEntry(db, testUserId, request)).not.toThrow(); + }); + + // null metadata + + it('null metadata is valid for any entry type', () => { + const types: CreateDiaryEntryRequest['entryType'][] = [ + 'daily_log', 'site_visit', 'delivery', 'issue', 'general_note', + ]; + for (const entryType of types) { + const request: CreateDiaryEntryRequest = { + entryType, + entryDate: '2026-03-14', + body: `${entryType} entry with null metadata`, + metadata: null, + }; + expect(() => createDiaryEntry(db, testUserId, request)).not.toThrow(); + } + }); + }); +}); diff --git a/server/src/services/diaryService.ts b/server/src/services/diaryService.ts new file mode 100644 index 000000000..a8df13e6f --- /dev/null +++ b/server/src/services/diaryService.ts @@ -0,0 +1,556 @@ +/** + * Diary service — CRUD for construction diary entries (Bautagebuch). + * + * EPIC-13: Construction Diary + * + * Manages manual and automatic diary entries with type-specific metadata validation. + * Supports pagination, filtering, and photo attachment management. + */ + +import { randomUUID } from 'node:crypto'; +import { eq, desc, and, or, gte, lte, inArray, sql } from 'drizzle-orm'; +import type { SQL } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type * as schemaTypes from '../db/schema.js'; +import { diaryEntries, photos, users } from '../db/schema.js'; +import { + NotFoundError, + ValidationError, + UnauthorizedError, + InvalidMetadataError, + ImmutableEntryError, + InvalidEntryTypeError, +} from '../errors/AppError.js'; +import { deletePhotosForEntity } from './photoService.js'; +import type { + DiaryEntrySummary, + DiaryEntryDetail, + CreateDiaryEntryRequest, + UpdateDiaryEntryRequest, + DiaryEntryListQuery, + DiaryUserSummary, + ManualDiaryEntryType, + DiaryEntryMetadata, + DailyLogMetadata, + SiteVisitMetadata, + DeliveryMetadata, + IssueMetadata, +} from '@cornerstone/shared'; +import type { PaginationMeta } from '@cornerstone/shared'; + +type DbType = BetterSQLite3Database; + +/** + * Manual diary entry types that can be created by users. + */ +const MANUAL_ENTRY_TYPES = new Set([ + 'daily_log', + 'site_visit', + 'delivery', + 'issue', + 'general_note', +]); + +/** + * Convert database user row to DiaryUserSummary shape. + */ +function toDiaryUserSummary(user: typeof users.$inferSelect | null): DiaryUserSummary | null { + if (!user) return null; + return { + id: user.id, + displayName: user.displayName, + }; +} + +/** + * Parse metadata from JSON string, returning null if not present or invalid. + */ +function parseMetadata(metadata: string | null): DiaryEntryMetadata | null { + if (!metadata) return null; + try { + return JSON.parse(metadata); + } catch { + return null; + } +} + +/** + * Convert database diary entry row to DiaryEntrySummary shape. + * Includes photo count aggregated from photos table. + */ +function toDiarySummary( + entry: typeof diaryEntries.$inferSelect, + user: typeof users.$inferSelect | null, + photoCount: number, +): DiaryEntrySummary { + return { + id: entry.id, + entryType: entry.entryType as any, + entryDate: entry.entryDate, + title: entry.title, + body: entry.body, + metadata: parseMetadata(entry.metadata), + isAutomatic: entry.isAutomatic, + sourceEntityType: entry.sourceEntityType as any, + sourceEntityId: entry.sourceEntityId, + photoCount, + createdBy: toDiaryUserSummary(user), + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + }; +} + +/** + * Validate metadata structure for a given entry type. + * @throws InvalidMetadataError if metadata does not match schema + */ +function validateMetadata(entryType: string, metadata: DiaryEntryMetadata | null | undefined): void { + if (!metadata) return; + + const md = metadata as Record; + + switch (entryType) { + case 'daily_log': { + const dlm = md as DailyLogMetadata; + // Validate weather enum + if (dlm.weather !== undefined && dlm.weather !== null) { + const validWeathers = ['sunny', 'cloudy', 'rainy', 'snowy', 'stormy', 'other']; + if (!validWeathers.includes(dlm.weather)) { + throw new InvalidMetadataError( + `daily_log weather must be one of: ${validWeathers.join(', ')}` + ); + } + } + // Validate temperatureCelsius is number or null + if (dlm.temperatureCelsius !== undefined && dlm.temperatureCelsius !== null) { + if (typeof dlm.temperatureCelsius !== 'number') { + throw new InvalidMetadataError('daily_log temperatureCelsius must be a number or null'); + } + } + // Validate workersOnSite is integer >= 0 or null + if (dlm.workersOnSite !== undefined && dlm.workersOnSite !== null) { + if (!Number.isInteger(dlm.workersOnSite) || dlm.workersOnSite < 0) { + throw new InvalidMetadataError( + 'daily_log workersOnSite must be a non-negative integer or null' + ); + } + } + // Validate hasSignature is boolean + if (dlm.hasSignature !== undefined && typeof dlm.hasSignature !== 'boolean') { + throw new InvalidMetadataError('daily_log hasSignature must be a boolean'); + } + break; + } + + case 'site_visit': { + const svm = md as SiteVisitMetadata; + // Validate inspectorName is string or null + if (svm.inspectorName !== undefined && svm.inspectorName !== null) { + if (typeof svm.inspectorName !== 'string') { + throw new InvalidMetadataError('site_visit inspectorName must be a string or null'); + } + } + // Validate outcome enum + if (svm.outcome !== undefined && svm.outcome !== null) { + const validOutcomes = ['pass', 'fail', 'conditional']; + if (!validOutcomes.includes(svm.outcome)) { + throw new InvalidMetadataError( + `site_visit outcome must be one of: ${validOutcomes.join(', ')}` + ); + } + } + // Validate hasSignature is boolean + if (svm.hasSignature !== undefined && typeof svm.hasSignature !== 'boolean') { + throw new InvalidMetadataError('site_visit hasSignature must be a boolean'); + } + break; + } + + case 'delivery': { + const dm = md as DeliveryMetadata; + // Validate vendor is string or null + if (dm.vendor !== undefined && dm.vendor !== null) { + if (typeof dm.vendor !== 'string') { + throw new InvalidMetadataError('delivery vendor must be a string or null'); + } + } + // Validate materials is array of strings or null + if (dm.materials !== undefined && dm.materials !== null) { + if (!Array.isArray(dm.materials)) { + throw new InvalidMetadataError('delivery materials must be an array or null'); + } + if (!dm.materials.every((m) => typeof m === 'string')) { + throw new InvalidMetadataError('delivery materials must be an array of strings'); + } + } + // Validate deliveryConfirmed is boolean + if (dm.deliveryConfirmed !== undefined && typeof dm.deliveryConfirmed !== 'boolean') { + throw new InvalidMetadataError('delivery deliveryConfirmed must be a boolean'); + } + break; + } + + case 'issue': { + const im = md as IssueMetadata; + // Validate severity enum + if (im.severity !== undefined && im.severity !== null) { + const validSeverities = ['low', 'medium', 'high', 'critical']; + if (!validSeverities.includes(im.severity)) { + throw new InvalidMetadataError( + `issue severity must be one of: ${validSeverities.join(', ')}` + ); + } + } + // Validate resolutionStatus enum + if (im.resolutionStatus !== undefined && im.resolutionStatus !== null) { + const validStatuses = ['open', 'in_progress', 'resolved']; + if (!validStatuses.includes(im.resolutionStatus)) { + throw new InvalidMetadataError( + `issue resolutionStatus must be one of: ${validStatuses.join(', ')}` + ); + } + } + break; + } + + case 'general_note': + // general_note accepts any metadata structure + break; + + default: + // For automatic types, validate less strictly + break; + } +} + +/** + * List diary entries with pagination, filtering, and search. + */ +export function listDiaryEntries( + db: DbType, + query: DiaryEntryListQuery, +): { items: DiaryEntrySummary[]; pagination: PaginationMeta } { + const page = Math.max(1, query.page ?? 1); + const pageSize = Math.min(100, Math.max(1, query.pageSize ?? 50)); + + // Build WHERE conditions + const conditions: SQL[] = []; + + if (query.type) { + type EntryTypeValue = typeof diaryEntries.entryType._.data; + const types = query.type.split(',').map((t) => t.trim()).filter(Boolean) as EntryTypeValue[]; + if (types.length === 1) { + conditions.push(eq(diaryEntries.entryType, types[0])); + } else if (types.length > 1) { + conditions.push(inArray(diaryEntries.entryType, types)); + } + } + + if (query.dateFrom) { + conditions.push(gte(diaryEntries.entryDate, query.dateFrom)); + } + + if (query.dateTo) { + conditions.push(lte(diaryEntries.entryDate, query.dateTo)); + } + + if (query.automatic !== undefined) { + conditions.push(eq(diaryEntries.isAutomatic, query.automatic)); + } + + if (query.q) { + // Escape SQL LIKE wildcards + const escapedQ = query.q.replace(/%/g, '\\%').replace(/_/g, '\\_'); + const pattern = `%${escapedQ}%`; + conditions.push( + or( + sql`LOWER(${diaryEntries.title}) LIKE LOWER(${pattern}) ESCAPE '\\'`, + sql`LOWER(${diaryEntries.body}) LIKE LOWER(${pattern}) ESCAPE '\\'`, + )!, + ); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // Count total items + const countResult = db + .select({ count: sql`COUNT(*)` }) + .from(diaryEntries) + .where(whereClause) + .get(); + const totalItems = countResult?.count ?? 0; + const totalPages = Math.ceil(totalItems / pageSize); + + // Fetch paginated entries with users and photo counts + const offset = (page - 1) * pageSize; + const entryRows = db + .select({ + entry: diaryEntries, + user: users, + }) + .from(diaryEntries) + .leftJoin(users, eq(users.id, diaryEntries.createdBy)) + .where(whereClause) + .orderBy(desc(diaryEntries.entryDate), desc(diaryEntries.createdAt)) + .limit(pageSize) + .offset(offset) + .all(); + + const items = entryRows.map((row) => { + const photoCount = db + .select({ count: sql`COUNT(*)` }) + .from(photos) + .where( + and(eq(photos.entityType, 'diary_entry'), eq(photos.entityId, row.entry.id)), + ) + .get(); + return toDiarySummary(row.entry, row.user, photoCount?.count ?? 0); + }); + + return { + items, + pagination: { + page, + pageSize, + totalItems, + totalPages, + }, + }; +} + +/** + * Get a single diary entry by ID. + * @throws NotFoundError if entry does not exist + */ +export function getDiaryEntry(db: DbType, id: string): DiaryEntryDetail { + const row = db + .select({ + entry: diaryEntries, + user: users, + }) + .from(diaryEntries) + .leftJoin(users, eq(users.id, diaryEntries.createdBy)) + .where(eq(diaryEntries.id, id)) + .get(); + + if (!row) { + throw new NotFoundError('Diary entry not found'); + } + + const photoCount = db + .select({ count: sql`COUNT(*)` }) + .from(photos) + .where( + and(eq(photos.entityType, 'diary_entry'), eq(photos.entityId, id)), + ) + .get(); + + return toDiarySummary(row.entry, row.user, photoCount?.count ?? 0); +} + +/** + * Create a new diary entry. + * Only manual entry types can be created by users; automatic types are system-generated. + * @throws ValidationError if entryType is automatic + * @throws InvalidMetadataError if metadata validation fails + */ +export function createDiaryEntry( + db: DbType, + userId: string, + data: CreateDiaryEntryRequest, +): DiaryEntrySummary { + // Validate entry type is manual + if (!MANUAL_ENTRY_TYPES.has(data.entryType)) { + throw new InvalidEntryTypeError( + 'Only manual entry types can be created: daily_log, site_visit, delivery, issue, general_note' + ); + } + + // Validate body is not empty + const trimmedBody = data.body.trim(); + if (trimmedBody.length === 0) { + throw new ValidationError('Entry body cannot be empty'); + } + + // Validate metadata + validateMetadata(data.entryType, data.metadata); + + // Create entry + const id = randomUUID(); + const now = new Date().toISOString(); + + db.insert(diaryEntries) + .values({ + id, + entryType: data.entryType, + entryDate: data.entryDate, + title: data.title || null, + body: trimmedBody, + metadata: data.metadata ? JSON.stringify(data.metadata) : null, + isAutomatic: false, + sourceEntityType: null, + sourceEntityId: null, + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + .run(); + + // Fetch created entry + const user = db.select().from(users).where(eq(users.id, userId)).get() || null; + + return toDiarySummary( + { + id, + entryType: data.entryType, + entryDate: data.entryDate, + title: data.title || null, + body: trimmedBody, + metadata: data.metadata ? JSON.stringify(data.metadata) : null, + isAutomatic: false, + sourceEntityType: null, + sourceEntityId: null, + createdBy: userId, + createdAt: now, + updatedAt: now, + }, + user, + 0, + ); +} + +/** + * Update a diary entry. + * Cannot update automatic entries. + * @throws NotFoundError if entry does not exist + * @throws ImmutableEntryError if entry is automatic + * @throws InvalidMetadataError if metadata validation fails + */ +export function updateDiaryEntry( + db: DbType, + id: string, + data: UpdateDiaryEntryRequest, +): DiaryEntrySummary { + // Fetch entry + const entry = db.select().from(diaryEntries).where(eq(diaryEntries.id, id)).get(); + + if (!entry) { + throw new NotFoundError('Diary entry not found'); + } + + // Cannot update automatic entries + if (entry.isAutomatic) { + throw new ImmutableEntryError(); + } + + // Validate body if provided + if (data.body !== undefined) { + const trimmedBody = data.body.trim(); + if (trimmedBody.length === 0) { + throw new ValidationError('Entry body cannot be empty'); + } + } + + // Validate metadata if provided + if (data.metadata !== undefined) { + validateMetadata(entry.entryType, data.metadata); + } + + // Update entry + const now = new Date().toISOString(); + db.update(diaryEntries) + .set({ + entryDate: data.entryDate ?? entry.entryDate, + title: data.title !== undefined ? data.title : entry.title, + body: data.body ? data.body.trim() : entry.body, + metadata: data.metadata !== undefined ? JSON.stringify(data.metadata) : entry.metadata, + updatedAt: now, + }) + .where(eq(diaryEntries.id, id)) + .run(); + + // Fetch updated entry + const row = db + .select({ + entry: diaryEntries, + user: users, + }) + .from(diaryEntries) + .leftJoin(users, eq(users.id, diaryEntries.createdBy)) + .where(eq(diaryEntries.id, id)) + .get(); + + const photoCount = db + .select({ count: sql`COUNT(*)` }) + .from(photos) + .where( + and(eq(photos.entityType, 'diary_entry'), eq(photos.entityId, id)), + ) + .get(); + + return toDiarySummary(row!.entry, row!.user, photoCount?.count ?? 0); +} + +/** + * Delete a diary entry and cascade-delete associated photos. + * Cannot delete automatic entries. + * @throws NotFoundError if entry does not exist + * @throws ImmutableEntryError if entry is automatic + */ +export async function deleteDiaryEntry( + db: DbType, + id: string, + photoStoragePath: string, +): Promise { + // Fetch entry + const entry = db.select().from(diaryEntries).where(eq(diaryEntries.id, id)).get(); + + if (!entry) { + throw new NotFoundError('Diary entry not found'); + } + + // Cannot delete automatic entries + if (entry.isAutomatic) { + throw new ImmutableEntryError(); + } + + // Delete associated photos + await deletePhotosForEntity(db, photoStoragePath, 'diary_entry', id); + + // Delete entry + db.delete(diaryEntries).where(eq(diaryEntries.id, id)).run(); +} + +/** + * Create an automatic diary entry (system-generated on state changes). + * For internal use only; not exposed via API. + */ +export function createAutomaticDiaryEntry( + db: DbType, + entryType: string, + entryDate: string, + body: string, + metadata: DiaryEntryMetadata | null, + sourceEntityType: string | null, + sourceEntityId: string | null, +): void { + const id = randomUUID(); + const now = new Date().toISOString(); + + db.insert(diaryEntries) + .values({ + id, + entryType: entryType as typeof diaryEntries.entryType._.data, + entryDate, + title: null, + body, + metadata: metadata ? JSON.stringify(metadata) : null, + isAutomatic: true, + sourceEntityType, + sourceEntityId, + createdBy: null, + createdAt: now, + updatedAt: now, + }) + .run(); +} diff --git a/shared/src/types/errors.ts b/shared/src/types/errors.ts index 2cf63c908..cbbbf5cd1 100644 --- a/shared/src/types/errors.ts +++ b/shared/src/types/errors.ts @@ -34,4 +34,7 @@ export type ErrorCode = | 'ITEMIZED_SUM_EXCEEDS_INVOICE' | 'BUDGET_LINE_ALREADY_LINKED' | 'RATE_LIMIT_EXCEEDED' - | 'ACCOUNT_LOCKED'; + | 'ACCOUNT_LOCKED' + | 'INVALID_METADATA' + | 'INVALID_ENTRY_TYPE' + | 'IMMUTABLE_ENTRY'; From 771d8074e9cc8df35210d62e42db28248e3057b9 Mon Sep 17 00:00:00 2001 From: "cornerstone-bot[bot]" Date: Sat, 14 Mar 2026 17:36:25 +0000 Subject: [PATCH 03/71] style: auto-fix lint and format [skip ci] --- server/src/db/schema.ts | 19 ++++++++++--- server/src/routes/diary.test.ts | 5 +++- server/src/services/diaryService.test.ts | 31 ++++++++++++++------- server/src/services/diaryService.ts | 34 ++++++++++++------------ 4 files changed, 58 insertions(+), 31 deletions(-) diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 0385fd2d7..2514df022 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -814,9 +814,17 @@ export const diaryEntries = sqliteTable( id: text('id').primaryKey(), entryType: text('entry_type', { enum: [ - 'daily_log', 'site_visit', 'delivery', 'issue', 'general_note', - 'work_item_status', 'invoice_status', 'milestone_delay', - 'budget_breach', 'auto_reschedule', 'subsidy_status', + 'daily_log', + 'site_visit', + 'delivery', + 'issue', + 'general_note', + 'work_item_status', + 'invoice_status', + 'milestone_delay', + 'budget_breach', + 'auto_reschedule', + 'subsidy_status', ], }).notNull(), entryDate: text('entry_date').notNull(), @@ -834,6 +842,9 @@ export const diaryEntries = sqliteTable( entryDateIdx: index('idx_diary_entries_entry_date').on(table.entryDate, table.createdAt), entryTypeIdx: index('idx_diary_entries_entry_type').on(table.entryType), isAutomaticIdx: index('idx_diary_entries_is_automatic').on(table.isAutomatic), - sourceEntityIdx: index('idx_diary_entries_source_entity').on(table.sourceEntityType, table.sourceEntityId), + sourceEntityIdx: index('idx_diary_entries_source_entity').on( + table.sourceEntityType, + table.sourceEntityId, + ), }), ); diff --git a/server/src/routes/diary.test.ts b/server/src/routes/diary.test.ts index 9ea7548ce..d6da1e5bf 100644 --- a/server/src/routes/diary.test.ts +++ b/server/src/routes/diary.test.ts @@ -221,7 +221,10 @@ describe('Diary Routes', () => { }); expect(response.statusCode).toBe(200); - const body = response.json<{ items: DiaryEntrySummary[]; pagination: { page: number; pageSize: number; totalItems: number; totalPages: number } }>(); + const body = response.json<{ + items: DiaryEntrySummary[]; + pagination: { page: number; pageSize: number; totalItems: number; totalPages: number }; + }>(); expect(body.items).toHaveLength(1); expect(body.pagination.page).toBe(2); expect(body.pagination.pageSize).toBe(2); diff --git a/server/src/services/diaryService.test.ts b/server/src/services/diaryService.test.ts index 0c9041bf0..8f8bbf2d2 100644 --- a/server/src/services/diaryService.test.ts +++ b/server/src/services/diaryService.test.ts @@ -347,9 +347,9 @@ describe('diaryService', () => { }); it('throws NotFoundError for unknown ID', () => { - expect(() => - updateDiaryEntry(db, 'does-not-exist', { body: 'Updated' }), - ).toThrow(NotFoundError); + expect(() => updateDiaryEntry(db, 'does-not-exist', { body: 'Updated' })).toThrow( + NotFoundError, + ); }); it('throws ImmutableEntryError for an automatic entry', () => { @@ -363,9 +363,9 @@ describe('diaryService', () => { it('throws InvalidMetadataError for invalid metadata on update', () => { const id = insertEntry({ entryType: 'site_visit' }); - expect(() => - updateDiaryEntry(db, id, { metadata: { outcome: 'maybe' } as any }), - ).toThrow(InvalidMetadataError); + expect(() => updateDiaryEntry(db, id, { metadata: { outcome: 'maybe' } as any })).toThrow( + InvalidMetadataError, + ); }); it('setting metadata to null clears it', () => { @@ -414,7 +414,11 @@ describe('diaryService', () => { 'work_item_status', '2026-03-14', 'Work item status changed to completed', - { changeSummary: 'Status: in_progress → completed', previousValue: 'in_progress', newValue: 'completed' }, + { + changeSummary: 'Status: in_progress → completed', + previousValue: 'in_progress', + newValue: 'completed', + }, 'work_item', 'wi-123', ); @@ -456,7 +460,12 @@ describe('diaryService', () => { entryType: 'daily_log', entryDate: '2026-03-14', body: 'Sunny day', - metadata: { weather: 'sunny', temperatureCelsius: 22, workersOnSite: 4, hasSignature: true }, + metadata: { + weather: 'sunny', + temperatureCelsius: 22, + workersOnSite: 4, + hasSignature: true, + }, }; expect(() => createDiaryEntry(db, testUserId, request)).not.toThrow(); }); @@ -533,7 +542,11 @@ describe('diaryService', () => { it('null metadata is valid for any entry type', () => { const types: CreateDiaryEntryRequest['entryType'][] = [ - 'daily_log', 'site_visit', 'delivery', 'issue', 'general_note', + 'daily_log', + 'site_visit', + 'delivery', + 'issue', + 'general_note', ]; for (const entryType of types) { const request: CreateDiaryEntryRequest = { diff --git a/server/src/services/diaryService.ts b/server/src/services/diaryService.ts index a8df13e6f..f57659acc 100644 --- a/server/src/services/diaryService.ts +++ b/server/src/services/diaryService.ts @@ -104,7 +104,10 @@ function toDiarySummary( * Validate metadata structure for a given entry type. * @throws InvalidMetadataError if metadata does not match schema */ -function validateMetadata(entryType: string, metadata: DiaryEntryMetadata | null | undefined): void { +function validateMetadata( + entryType: string, + metadata: DiaryEntryMetadata | null | undefined, +): void { if (!metadata) return; const md = metadata as Record; @@ -117,7 +120,7 @@ function validateMetadata(entryType: string, metadata: DiaryEntryMetadata | null const validWeathers = ['sunny', 'cloudy', 'rainy', 'snowy', 'stormy', 'other']; if (!validWeathers.includes(dlm.weather)) { throw new InvalidMetadataError( - `daily_log weather must be one of: ${validWeathers.join(', ')}` + `daily_log weather must be one of: ${validWeathers.join(', ')}`, ); } } @@ -131,7 +134,7 @@ function validateMetadata(entryType: string, metadata: DiaryEntryMetadata | null if (dlm.workersOnSite !== undefined && dlm.workersOnSite !== null) { if (!Number.isInteger(dlm.workersOnSite) || dlm.workersOnSite < 0) { throw new InvalidMetadataError( - 'daily_log workersOnSite must be a non-negative integer or null' + 'daily_log workersOnSite must be a non-negative integer or null', ); } } @@ -155,7 +158,7 @@ function validateMetadata(entryType: string, metadata: DiaryEntryMetadata | null const validOutcomes = ['pass', 'fail', 'conditional']; if (!validOutcomes.includes(svm.outcome)) { throw new InvalidMetadataError( - `site_visit outcome must be one of: ${validOutcomes.join(', ')}` + `site_visit outcome must be one of: ${validOutcomes.join(', ')}`, ); } } @@ -197,7 +200,7 @@ function validateMetadata(entryType: string, metadata: DiaryEntryMetadata | null const validSeverities = ['low', 'medium', 'high', 'critical']; if (!validSeverities.includes(im.severity)) { throw new InvalidMetadataError( - `issue severity must be one of: ${validSeverities.join(', ')}` + `issue severity must be one of: ${validSeverities.join(', ')}`, ); } } @@ -206,7 +209,7 @@ function validateMetadata(entryType: string, metadata: DiaryEntryMetadata | null const validStatuses = ['open', 'in_progress', 'resolved']; if (!validStatuses.includes(im.resolutionStatus)) { throw new InvalidMetadataError( - `issue resolutionStatus must be one of: ${validStatuses.join(', ')}` + `issue resolutionStatus must be one of: ${validStatuses.join(', ')}`, ); } } @@ -238,7 +241,10 @@ export function listDiaryEntries( if (query.type) { type EntryTypeValue = typeof diaryEntries.entryType._.data; - const types = query.type.split(',').map((t) => t.trim()).filter(Boolean) as EntryTypeValue[]; + const types = query.type + .split(',') + .map((t) => t.trim()) + .filter(Boolean) as EntryTypeValue[]; if (types.length === 1) { conditions.push(eq(diaryEntries.entryType, types[0])); } else if (types.length > 1) { @@ -300,9 +306,7 @@ export function listDiaryEntries( const photoCount = db .select({ count: sql`COUNT(*)` }) .from(photos) - .where( - and(eq(photos.entityType, 'diary_entry'), eq(photos.entityId, row.entry.id)), - ) + .where(and(eq(photos.entityType, 'diary_entry'), eq(photos.entityId, row.entry.id))) .get(); return toDiarySummary(row.entry, row.user, photoCount?.count ?? 0); }); @@ -340,9 +344,7 @@ export function getDiaryEntry(db: DbType, id: string): DiaryEntryDetail { const photoCount = db .select({ count: sql`COUNT(*)` }) .from(photos) - .where( - and(eq(photos.entityType, 'diary_entry'), eq(photos.entityId, id)), - ) + .where(and(eq(photos.entityType, 'diary_entry'), eq(photos.entityId, id))) .get(); return toDiarySummary(row.entry, row.user, photoCount?.count ?? 0); @@ -362,7 +364,7 @@ export function createDiaryEntry( // Validate entry type is manual if (!MANUAL_ENTRY_TYPES.has(data.entryType)) { throw new InvalidEntryTypeError( - 'Only manual entry types can be created: daily_log, site_visit, delivery, issue, general_note' + 'Only manual entry types can be created: daily_log, site_visit, delivery, issue, general_note', ); } @@ -483,9 +485,7 @@ export function updateDiaryEntry( const photoCount = db .select({ count: sql`COUNT(*)` }) .from(photos) - .where( - and(eq(photos.entityType, 'diary_entry'), eq(photos.entityId, id)), - ) + .where(and(eq(photos.entityType, 'diary_entry'), eq(photos.entityId, id))) .get(); return toDiarySummary(row!.entry, row!.user, photoCount?.count ?? 0); From 1b1747c2c9b642f707507d1a7e35f58dfb335a6a Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Sat, 14 Mar 2026 20:51:28 +0100 Subject: [PATCH 04/71] feat(diary): add diary timeline view and detail page (#813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(diary): add diary timeline view and detail page Implement the Construction Diary UI with: - Diary list page at /diary with chronological timeline grouped by date - Detail page at /diary/:id with full type-specific metadata rendering - 8 new diary components (badges, cards, filters, metadata summary) - API client for all diary endpoints - Design tokens for entry types, outcomes, severity levels - Dark mode support, responsive layout, accessibility (ARIA) - Sidebar navigation with "Diary" link - E2E page objects and test specs - Unit tests (147 tests across 9 files) Fixes #804 Co-Authored-By: Claude frontend-developer (Haiku) Co-Authored-By: Claude qa-integration-tester (Sonnet) Co-Authored-By: Claude e2e-test-engineer (Sonnet) Co-Authored-By: Claude dev-team-lead (Sonnet) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(diary): cast changeSummary to string for ReactNode compatibility Co-Authored-By: Claude Opus 4.6 (1M context) * fix(diary): fix failing tests — sidebar, detail page, entry card - Update Sidebar.test.tsx to expect 6 nav links (added Diary) - Fix DiaryEntryDetailPage back button aria-label - Fix DiaryEntryCard.test.tsx module import Co-Authored-By: Claude Opus 4.6 (1M context) * fix(diary): remove CSS module mocks causing useContext null errors Remove jest.unstable_mockModule CSS mock blocks from all diary test files. The root jest config already handles CSS modules via moduleNameMapper with identity-obj-proxy. Double-mocking caused React to load as a separate instance, breaking context APIs. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(diary): fix dual React instance in page tests Import page components once and avoid jest.resetModules() which causes React to be loaded as a separate instance from react-router-dom. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(diary): remove stale .default reference in DiaryPage test Co-Authored-By: Claude Opus 4.6 (1M context) * fix(e2e): use getByLabel for diary detail back button The button uses aria-label, not title attribute. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude product-architect (Opus 4.6) --- client/src/App.tsx | 22 + .../src/components/Sidebar/Sidebar.test.tsx | 30 +- client/src/components/Sidebar/Sidebar.tsx | 7 + .../DiaryDateGroup/DiaryDateGroup.module.css | 18 + .../diary/DiaryDateGroup/DiaryDateGroup.tsx | 32 + .../DiaryEntryCard/DiaryEntryCard.module.css | 112 ++++ .../DiaryEntryCard/DiaryEntryCard.test.tsx | 187 ++++++ .../diary/DiaryEntryCard/DiaryEntryCard.tsx | 97 +++ .../DiaryEntryTypeBadge.module.css | 59 ++ .../DiaryEntryTypeBadge.test.tsx | 124 ++++ .../DiaryEntryTypeBadge.tsx | 64 ++ .../DiaryEntryTypeSwitcher.module.css | 47 ++ .../DiaryEntryTypeSwitcher.test.tsx | 188 ++++++ .../DiaryEntryTypeSwitcher.tsx | 62 ++ .../DiaryFilterBar/DiaryFilterBar.module.css | 149 +++++ .../DiaryFilterBar/DiaryFilterBar.test.tsx | 221 +++++++ .../diary/DiaryFilterBar/DiaryFilterBar.tsx | 173 ++++++ .../DiaryMetadataSummary.module.css | 24 + .../DiaryMetadataSummary.tsx | 102 +++ .../DiaryOutcomeBadge.module.css | 27 + .../DiaryOutcomeBadge.test.tsx | 67 ++ .../DiaryOutcomeBadge/DiaryOutcomeBadge.tsx | 20 + .../DiarySeverityBadge.module.css | 33 + .../DiarySeverityBadge.test.tsx | 78 +++ .../DiarySeverityBadge/DiarySeverityBadge.tsx | 21 + client/src/lib/diaryApi.test.ts | 399 ++++++++++++ client/src/lib/diaryApi.ts | 74 +++ client/src/lib/formatters.ts | 46 ++ .../DiaryEntryDetailPage.module.css | 201 ++++++ .../DiaryEntryDetailPage.test.tsx | 369 +++++++++++ .../DiaryEntryDetailPage.tsx | 198 ++++++ .../src/pages/DiaryPage/DiaryPage.module.css | 87 +++ client/src/pages/DiaryPage/DiaryPage.test.tsx | 284 +++++++++ client/src/pages/DiaryPage/DiaryPage.tsx | 286 +++++++++ client/src/styles/tokens.css | 108 ++++ e2e/fixtures/apiHelpers.ts | 24 + e2e/fixtures/testData.ts | 2 + e2e/pages/DiaryEntryDetailPage.ts | 152 +++++ e2e/pages/DiaryPage.ts | 194 ++++++ e2e/tests/diary/diary-detail.spec.ts | 452 ++++++++++++++ e2e/tests/diary/diary-list.spec.ts | 580 ++++++++++++++++++ 41 files changed, 5415 insertions(+), 5 deletions(-) create mode 100644 client/src/components/diary/DiaryDateGroup/DiaryDateGroup.module.css create mode 100644 client/src/components/diary/DiaryDateGroup/DiaryDateGroup.tsx create mode 100644 client/src/components/diary/DiaryEntryCard/DiaryEntryCard.module.css create mode 100644 client/src/components/diary/DiaryEntryCard/DiaryEntryCard.test.tsx create mode 100644 client/src/components/diary/DiaryEntryCard/DiaryEntryCard.tsx create mode 100644 client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.module.css create mode 100644 client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.test.tsx create mode 100644 client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.tsx create mode 100644 client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.module.css create mode 100644 client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.test.tsx create mode 100644 client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.tsx create mode 100644 client/src/components/diary/DiaryFilterBar/DiaryFilterBar.module.css create mode 100644 client/src/components/diary/DiaryFilterBar/DiaryFilterBar.test.tsx create mode 100644 client/src/components/diary/DiaryFilterBar/DiaryFilterBar.tsx create mode 100644 client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.module.css create mode 100644 client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.tsx create mode 100644 client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.module.css create mode 100644 client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.test.tsx create mode 100644 client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.tsx create mode 100644 client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.module.css create mode 100644 client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.test.tsx create mode 100644 client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.tsx create mode 100644 client/src/lib/diaryApi.test.ts create mode 100644 client/src/lib/diaryApi.ts create mode 100644 client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.module.css create mode 100644 client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.test.tsx create mode 100644 client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.tsx create mode 100644 client/src/pages/DiaryPage/DiaryPage.module.css create mode 100644 client/src/pages/DiaryPage/DiaryPage.test.tsx create mode 100644 client/src/pages/DiaryPage/DiaryPage.tsx create mode 100644 e2e/pages/DiaryEntryDetailPage.ts create mode 100644 e2e/pages/DiaryPage.ts create mode 100644 e2e/tests/diary/diary-detail.spec.ts create mode 100644 e2e/tests/diary/diary-list.spec.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 44008ac55..9343a46dc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -65,6 +65,8 @@ const ProfilePage = lazy(() => import('./pages/ProfilePage/ProfilePage')); const UserManagementPage = lazy(() => import('./pages/UserManagementPage/UserManagementPage')); const InvoicesPage = lazy(() => import('./pages/InvoicesPage/InvoicesPage')); const InvoiceDetailPage = lazy(() => import('./pages/InvoiceDetailPage/InvoiceDetailPage')); +const DiaryPage = lazy(() => import('./pages/DiaryPage/DiaryPage')); +const DiaryEntryDetailPage = lazy(() => import('./pages/DiaryEntryDetailPage/DiaryEntryDetailPage')); const NotFoundPage = lazy(() => import('./pages/NotFoundPage/NotFoundPage')); export function App() { @@ -138,6 +140,26 @@ export function App() { } /> + {/* Diary section */} + + Loading...}> + + + } + /> + Loading...}> + + + } + /> + + {/* Settings section */} } /> diff --git a/client/src/components/Sidebar/Sidebar.test.tsx b/client/src/components/Sidebar/Sidebar.test.tsx index 8edbf9109..3c4ddce21 100644 --- a/client/src/components/Sidebar/Sidebar.test.tsx +++ b/client/src/components/Sidebar/Sidebar.test.tsx @@ -64,13 +64,13 @@ describe('Sidebar', () => { onClose: mockOnClose, }); - it('renders 3 navigation links plus 1 logo link plus 1 GitHub footer link', () => { + it('renders 4 navigation links plus 1 logo link plus 1 GitHub footer link', () => { renderWithRouter(); const links = screen.getAllByRole('link'); - // 3 main nav links (Project, Budget, Schedule) + 1 logo link (Go to project overview) + // 4 main nav links (Project, Budget, Schedule, Diary) + 1 logo link (Go to project overview) // + 1 GitHub link in the footer (Settings is now a button, not a link) - expect(links).toHaveLength(5); + expect(links).toHaveLength(6); }); it('logo link navigates to /project and has aria-label', () => { @@ -94,6 +94,7 @@ describe('Sidebar', () => { expect(screen.getByRole('link', { name: /^project$/i })).toHaveAttribute('href', '/project'); expect(screen.getByRole('link', { name: /^budget$/i })).toHaveAttribute('href', '/budget'); expect(screen.getByRole('link', { name: /^schedule$/i })).toHaveAttribute('href', '/schedule'); + expect(screen.getByRole('link', { name: /^diary$/i })).toHaveAttribute('href', '/diary'); // Settings is now a button (programmatic navigation), not a link expect(screen.getByRole('button', { name: /^settings$/i })).toBeInTheDocument(); }); @@ -134,6 +135,15 @@ describe('Sidebar', () => { expect(scheduleLink).toHaveClass('active'); }); + it('diary link is active at /diary', () => { + renderWithRouter(, { + initialEntries: ['/diary'], + }); + + const diaryLink = screen.getByRole('link', { name: /^diary$/i }); + expect(diaryLink).toHaveClass('active'); + }); + it('settings button is active at /settings', () => { renderWithRouter(, { initialEntries: ['/settings'], @@ -217,6 +227,16 @@ describe('Sidebar', () => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); + it('clicking a nav link calls onClose (diary)', async () => { + const user = userEvent.setup(); + renderWithRouter(); + + const diaryLink = screen.getByRole('link', { name: /^diary$/i }); + await user.click(diaryLink); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + it('clicking settings button calls onClose', async () => { const user = userEvent.setup(); renderWithRouter(); @@ -289,9 +309,9 @@ describe('Sidebar', () => { const links = screen.getAllByRole('link'); const buttons = screen.getAllByRole('button'); - // 3 main nav links (Project, Budget, Schedule) + 1 logo link + 1 GitHub link + // 4 main nav links (Project, Budget, Schedule, Diary) + 1 logo link + 1 GitHub link // (Settings is now a button, not a link) - expect(links).toHaveLength(5); + expect(links).toHaveLength(6); // 4 buttons: close button + theme toggle + settings button + logout button expect(buttons).toHaveLength(4); expect(buttons[0]).toHaveAttribute('aria-label', 'Close menu'); diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index 8b03cd640..532c74473 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -53,6 +53,13 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) { > Schedule + `${styles.navLink} ${isActive ? styles.active : ''}`} + onClick={onClose} + > + Diary +
diff --git a/client/src/components/diary/DiaryDateGroup/DiaryDateGroup.module.css b/client/src/components/diary/DiaryDateGroup/DiaryDateGroup.module.css new file mode 100644 index 000000000..37ac64c35 --- /dev/null +++ b/client/src/components/diary/DiaryDateGroup/DiaryDateGroup.module.css @@ -0,0 +1,18 @@ +.group { + margin-bottom: var(--spacing-8); +} + +.dateHeader { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin-bottom: var(--spacing-4); + padding-bottom: var(--spacing-3); + border-bottom: 2px solid var(--color-border-strong); +} + +.entriesList { + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} diff --git a/client/src/components/diary/DiaryDateGroup/DiaryDateGroup.tsx b/client/src/components/diary/DiaryDateGroup/DiaryDateGroup.tsx new file mode 100644 index 000000000..c6f176c4d --- /dev/null +++ b/client/src/components/diary/DiaryDateGroup/DiaryDateGroup.tsx @@ -0,0 +1,32 @@ +import type { DiaryEntrySummary } from '@cornerstone/shared'; +import { DiaryEntryCard } from '../DiaryEntryCard/DiaryEntryCard.js'; +import styles from './DiaryDateGroup.module.css'; + +interface DiaryDateGroupProps { + date: string; + entries: DiaryEntrySummary[]; +} + +function formatDateHeader(dateString: string): string { + const date = new Date(dateString); + const options: Intl.DateTimeFormatOptions = { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }; + return date.toLocaleDateString('en-US', options); +} + +export function DiaryDateGroup({ date, entries }: DiaryDateGroupProps) { + return ( +
+

{formatDateHeader(date)}

+
+ {entries.map((entry) => ( + + ))} +
+
+ ); +} diff --git a/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.module.css b/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.module.css new file mode 100644 index 000000000..29462a7dd --- /dev/null +++ b/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.module.css @@ -0,0 +1,112 @@ +.card { + display: block; + padding: var(--spacing-4); + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + transition: var(--transition-normal); + text-decoration: none; + color: inherit; + box-shadow: var(--shadow-sm); +} + +.card:hover { + box-shadow: var(--shadow-md); + border-color: var(--color-border-strong); +} + +.card:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +/* Automatic entry styling (muted) */ +.automatic { + background-color: var(--color-bg-secondary); + border-left: 3px solid var(--color-diary-automatic-border); + box-shadow: none; +} + +.automatic:hover { + box-shadow: var(--shadow-sm); + background-color: var(--color-bg-tertiary); +} + +.header { + display: flex; + gap: var(--spacing-3); + margin-bottom: var(--spacing-3); + align-items: flex-start; +} + +.headerText { + flex: 1; + min-width: 0; +} + +.title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin-bottom: var(--spacing-1); + word-break: break-word; +} + +.timestamp { + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.author { + color: var(--color-text-secondary); +} + +.body { + font-size: var(--font-size-sm); + color: var(--color-text-body); + line-height: 1.5; + margin-bottom: var(--spacing-3); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +} + +.footer { + display: flex; + gap: var(--spacing-3); + align-items: center; + flex-wrap: wrap; + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.photoCount { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + color: var(--color-text-secondary); +} + +.sourceLink { + display: inline-block; + padding: var(--spacing-1) var(--spacing-2); + background-color: var(--color-bg-tertiary); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + color: var(--color-primary); + text-decoration: none; + transition: var(--transition-normal); +} + +.sourceLink:hover { + background-color: var(--color-primary-bg); + border-color: var(--color-primary); +} + +.sourceLink:focus-visible { + outline: none; + box-shadow: var(--shadow-focus-subtle); +} diff --git a/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.test.tsx b/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.test.tsx new file mode 100644 index 000000000..baacb7a68 --- /dev/null +++ b/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.test.tsx @@ -0,0 +1,187 @@ +/** + * @jest-environment jsdom + */ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { screen, render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { DiaryEntrySummary } from '@cornerstone/shared'; +import { DiaryEntryCard } from './DiaryEntryCard.js'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const manualEntry: DiaryEntrySummary = { + id: 'de-manual-1', + entryType: 'daily_log', + entryDate: '2026-03-14', + title: 'Daily Site Log', + body: 'Poured concrete foundations and inspected rebar placement.', + metadata: null, + isAutomatic: false, + sourceEntityType: null, + sourceEntityId: null, + photoCount: 0, + createdBy: { id: 'user-1', displayName: 'Alice Builder' }, + createdAt: '2026-03-14T09:30:00.000Z', + updatedAt: '2026-03-14T09:30:00.000Z', +}; + +const automaticEntry: DiaryEntrySummary = { + id: 'de-auto-1', + entryType: 'work_item_status', + entryDate: '2026-03-14', + title: null, + body: 'Work item "Kitchen Installation" changed status to in_progress.', + metadata: { changeSummary: 'Status changed to in_progress', previousValue: 'not_started', newValue: 'in_progress' }, + isAutomatic: true, + sourceEntityType: 'work_item', + sourceEntityId: 'wi-kitchen-1', + photoCount: 0, + createdBy: null, + createdAt: '2026-03-14T10:00:00.000Z', + updatedAt: '2026-03-14T10:00:00.000Z', +}; + +describe('DiaryEntryCard', () => { + beforeEach(() => { + localStorage.setItem('theme', 'light'); + }); + + afterEach(() => { + localStorage.clear(); + }); + + const renderCard = (entry: DiaryEntrySummary) => + render( + + + , + ); + + // ─── Manual entry rendering ───────────────────────────────────────────────── + + it('renders the entry title for manual entries', () => { + renderCard(manualEntry); + expect(screen.getByText('Daily Site Log')).toBeInTheDocument(); + }); + + it('renders the body text for manual entries', () => { + renderCard(manualEntry); + expect( + screen.getByText('Poured concrete foundations and inspected rebar placement.'), + ).toBeInTheDocument(); + }); + + it('renders the author display name', () => { + renderCard(manualEntry); + expect(screen.getByText(/Alice Builder/i)).toBeInTheDocument(); + }); + + it('links to /diary/:id', () => { + renderCard(manualEntry); + const card = screen.getByTestId('diary-card-de-manual-1'); + expect(card).toHaveAttribute('href', '/diary/de-manual-1'); + }); + + it('does not show the photo count indicator when photoCount is 0', () => { + renderCard(manualEntry); + expect(screen.queryByTestId('photo-count-de-manual-1')).not.toBeInTheDocument(); + }); + + it('shows the photo count indicator with 📷 when photoCount > 0', () => { + const entryWithPhotos: DiaryEntrySummary = { ...manualEntry, id: 'de-photos', photoCount: 3 }; + renderCard(entryWithPhotos); + const indicator = screen.getByTestId('photo-count-de-photos'); + expect(indicator).toBeInTheDocument(); + expect(indicator.textContent).toContain('3'); + expect(indicator.textContent).toContain('📷'); + }); + + it('does not show source link for manual entries without source entity', () => { + renderCard(manualEntry); + expect(screen.queryByTestId(/source-link/)).not.toBeInTheDocument(); + }); + + // ─── Automatic entry rendering ────────────────────────────────────────────── + + it('applies the "automatic" CSS class to automatic entries', () => { + renderCard(automaticEntry); + const card = screen.getByTestId('diary-card-de-auto-1'); + expect(card.getAttribute('class') ?? '').toContain('automatic'); + }); + + it('does not apply "automatic" CSS class to manual entries', () => { + renderCard(manualEntry); + const card = screen.getByTestId('diary-card-de-manual-1'); + expect(card.getAttribute('class') ?? '').not.toContain('automatic'); + }); + + it('renders the body of an automatic entry', () => { + renderCard(automaticEntry); + expect( + screen.getByText('Work item "Kitchen Installation" changed status to in_progress.'), + ).toBeInTheDocument(); + }); + + it('renders a source entity link for automatic entries with work_item source', () => { + renderCard(automaticEntry); + const sourceLink = screen.getByTestId('source-link-wi-kitchen-1'); + expect(sourceLink).toBeInTheDocument(); + expect(sourceLink).toHaveTextContent('Work Item'); + }); + + it('source entity link for work_item points to /project/work-items/:sourceEntityId', () => { + renderCard(automaticEntry); + const sourceLink = screen.getByTestId('source-link-wi-kitchen-1'); + expect(sourceLink).toHaveAttribute('href', '/project/work-items/wi-kitchen-1'); + }); + + it('renders invoice source link with correct route', () => { + const invoiceEntry: DiaryEntrySummary = { + ...automaticEntry, + id: 'de-inv', + entryType: 'invoice_status', + sourceEntityType: 'invoice', + sourceEntityId: 'inv-123', + }; + renderCard(invoiceEntry); + const sourceLink = screen.getByTestId('source-link-inv-123'); + expect(sourceLink).toHaveTextContent('Invoice'); + expect(sourceLink).toHaveAttribute('href', '/budget/invoices/inv-123'); + }); + + it('renders milestone source link with correct route', () => { + const milestoneEntry: DiaryEntrySummary = { + ...automaticEntry, + id: 'de-ms', + entryType: 'milestone_delay', + sourceEntityType: 'milestone', + sourceEntityId: 'ms-456', + }; + renderCard(milestoneEntry); + const sourceLink = screen.getByTestId('source-link-ms-456'); + expect(sourceLink).toHaveTextContent('Milestone'); + expect(sourceLink).toHaveAttribute('href', '/project/milestones/ms-456'); + }); + + // ─── Type badge ───────────────────────────────────────────────────────────── + + it('renders the type badge with correct testid', () => { + renderCard(manualEntry); + expect(screen.getByTestId('diary-type-badge-daily_log')).toBeInTheDocument(); + }); + + it('renders ⚙️ badge for automatic entry types', () => { + renderCard(automaticEntry); + const badge = screen.getByTestId('diary-type-badge-work_item_status'); + expect(badge.textContent).toBe('⚙️'); + }); + + // ─── No title for automatic entries ──────────────────────────────────────── + + it('does not render a title element when title is null', () => { + renderCard(automaticEntry); + // The card link is present but no title div + const card = screen.getByTestId('diary-card-de-auto-1'); + expect(card.querySelector('[class*="title"]')).toBeNull(); + }); +}); diff --git a/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.tsx b/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.tsx new file mode 100644 index 000000000..0be48a138 --- /dev/null +++ b/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.tsx @@ -0,0 +1,97 @@ +import { Link } from 'react-router-dom'; +import type { DiaryEntrySummary } from '@cornerstone/shared'; +import { formatDate, formatTime } from '../../../lib/formatters.js'; +import { DiaryEntryTypeBadge } from '../DiaryEntryTypeBadge/DiaryEntryTypeBadge.js'; +import { DiaryMetadataSummary } from '../DiaryMetadataSummary/DiaryMetadataSummary.js'; +import styles from './DiaryEntryCard.module.css'; + +interface DiaryEntryCardProps { + entry: DiaryEntrySummary; +} + +function getSourceEntityRoute(entry: DiaryEntrySummary): string | null { + if (!entry.sourceEntityType || !entry.sourceEntityId) { + return null; + } + + switch (entry.sourceEntityType) { + case 'work_item': + return `/project/work-items/${entry.sourceEntityId}`; + case 'invoice': + return `/budget/invoices/${entry.sourceEntityId}`; + case 'milestone': + return `/project/milestones/${entry.sourceEntityId}`; + case 'budget_source': + return `/budget/sources`; + case 'subsidy_program': + return `/budget/subsidies`; + default: + return null; + } +} + +function getSourceEntityLabel(sourceType: string): string { + switch (sourceType) { + case 'work_item': + return 'Work Item'; + case 'invoice': + return 'Invoice'; + case 'milestone': + return 'Milestone'; + case 'budget_source': + return 'Budget Source'; + case 'subsidy_program': + return 'Subsidy Program'; + default: + return sourceType; + } +} + +export function DiaryEntryCard({ entry }: DiaryEntryCardProps) { + const route = getSourceEntityRoute(entry); + const sourceLabel = entry.sourceEntityType ? getSourceEntityLabel(entry.sourceEntityType) : null; + const cardClassName = [styles.card, entry.isAutomatic && styles.automatic] + .filter(Boolean) + .join(' '); + + return ( + +
+ +
+ {entry.title &&
{entry.title}
} +
+ {formatTime(entry.createdAt)} + {entry.createdBy && by {entry.createdBy.displayName}} +
+
+
+ +
{entry.body}
+ + {entry.metadata && ( + + )} + +
+ {entry.photoCount > 0 && ( + + 📷 {entry.photoCount} + + )} + + {route && sourceLabel && ( + e.preventDefault()} + title={sourceLabel} + data-testid={`source-link-${entry.sourceEntityId}`} + > + {sourceLabel} + + )} +
+ + ); +} diff --git a/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.module.css b/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.module.css new file mode 100644 index 000000000..3d6e59915 --- /dev/null +++ b/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.module.css @@ -0,0 +1,59 @@ +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: none; + cursor: default; +} + +.sizeSm { + width: var(--spacing-10); /* 40px */ + height: var(--spacing-10); + font-size: 1.25rem; /* emoji size */ +} + +.sizeLg { + width: var(--spacing-12); /* 48px */ + height: var(--spacing-12); + font-size: 1.5rem; /* emoji size */ +} + +/* Daily log (blue) */ +.dailyLog { + background-color: var(--color-diary-daily-log-bg); + color: var(--color-diary-daily-log-text); +} + +/* Site visit (teal) */ +.siteVisit { + background-color: var(--color-diary-site-visit-bg); + color: var(--color-diary-site-visit-text); +} + +/* Delivery (amber) */ +.delivery { + background-color: var(--color-diary-delivery-bg); + color: var(--color-diary-delivery-text); +} + +/* Issue (red) */ +.issue { + background-color: var(--color-diary-issue-bg); + color: var(--color-diary-issue-text); +} + +/* General note (gray) */ +.generalNote { + background-color: var(--color-diary-general-note-bg); + color: var(--color-diary-general-note-text); +} + +/* Automatic entry (gray, muted) */ +.automatic { + background-color: var(--color-diary-automatic-bg); + color: var(--color-diary-automatic-text); + border: 1px solid var(--color-diary-automatic-border); +} diff --git a/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.test.tsx b/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.test.tsx new file mode 100644 index 000000000..4a8bd7d2f --- /dev/null +++ b/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.test.tsx @@ -0,0 +1,124 @@ +/** + * @jest-environment jsdom + */ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { screen, render } from '@testing-library/react'; +import { DiaryEntryTypeBadge } from './DiaryEntryTypeBadge.js'; + +describe('DiaryEntryTypeBadge', () => { + beforeEach(() => { + localStorage.setItem('theme', 'light'); + }); + + afterEach(() => { + localStorage.clear(); + }); + + // ─── Manual types — distinct emoji ───────────────────────────────────────── + + it('renders 📋 for daily_log type', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-daily_log'); + expect(badge.textContent).toBe('📋'); + }); + + it('renders 🔍 for site_visit type', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-site_visit'); + expect(badge.textContent).toBe('🔍'); + }); + + it('renders 📦 for delivery type', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-delivery'); + expect(badge.textContent).toBe('📦'); + }); + + it('renders ⚠️ for issue type', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-issue'); + expect(badge.textContent).toBe('⚠️'); + }); + + it('renders 📝 for general_note type', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-general_note'); + expect(badge.textContent).toBe('📝'); + }); + + // ─── Automatic types — all use ⚙️ ────────────────────────────────────────── + + it('renders ⚙️ for work_item_status (automatic)', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-work_item_status'); + expect(badge.textContent).toBe('⚙️'); + }); + + it('renders ⚙️ for invoice_status (automatic)', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-invoice_status'); + expect(badge.textContent).toBe('⚙️'); + }); + + it('renders ⚙️ for milestone_delay (automatic)', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-milestone_delay'); + expect(badge.textContent).toBe('⚙️'); + }); + + it('renders ⚙️ for budget_breach (automatic)', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-budget_breach'); + expect(badge.textContent).toBe('⚙️'); + }); + + it('renders ⚙️ for auto_reschedule (automatic)', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-auto_reschedule'); + expect(badge.textContent).toBe('⚙️'); + }); + + it('renders ⚙️ for subsidy_status (automatic)', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-subsidy_status'); + expect(badge.textContent).toBe('⚙️'); + }); + + // ─── title attribute ──────────────────────────────────────────────────────── + + it('has title attribute matching the entry type label', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-daily_log'); + expect(badge).toHaveAttribute('title', 'Daily Log'); + }); + + it('has title "Site Visit" for site_visit', () => { + render(); + expect(screen.getByTestId('diary-type-badge-site_visit')).toHaveAttribute('title', 'Site Visit'); + }); + + // ─── size prop ───────────────────────────────────────────────────────────── + + it('applies sizeSm class by default (no size prop)', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-daily_log'); + const classAttr = badge.getAttribute('class') ?? ''; + expect(classAttr).toContain('sizeSm'); + expect(classAttr).not.toContain('sizeLg'); + }); + + it('applies sizeLg class when size="lg"', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-daily_log'); + const classAttr = badge.getAttribute('class') ?? ''; + expect(classAttr).toContain('sizeLg'); + expect(classAttr).not.toContain('sizeSm'); + }); + + it('applies sizeSm class when size="sm"', () => { + render(); + const badge = screen.getByTestId('diary-type-badge-issue'); + const classAttr = badge.getAttribute('class') ?? ''; + expect(classAttr).toContain('sizeSm'); + }); +}); diff --git a/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.tsx b/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.tsx new file mode 100644 index 000000000..714ec0de1 --- /dev/null +++ b/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.tsx @@ -0,0 +1,64 @@ +import type { DiaryEntryType } from '@cornerstone/shared'; +import styles from './DiaryEntryTypeBadge.module.css'; + +interface DiaryEntryTypeBadgeProps { + entryType: DiaryEntryType; + size?: 'sm' | 'lg'; +} + +const ENTRY_TYPE_LABELS: Record = { + daily_log: 'Daily Log', + site_visit: 'Site Visit', + delivery: 'Delivery', + issue: 'Issue', + general_note: 'Note', + work_item_status: 'Work Item', + invoice_status: 'Invoice', + milestone_delay: 'Milestone', + budget_breach: 'Budget', + auto_reschedule: 'Schedule', + subsidy_status: 'Subsidy', +}; + +const EMOJI_MAP: Record = { + daily_log: '📋', + site_visit: '🔍', + delivery: '📦', + issue: '⚠️', + general_note: '📝', + work_item_status: '⚙️', + invoice_status: '⚙️', + milestone_delay: '⚙️', + budget_breach: '⚙️', + auto_reschedule: '⚙️', + subsidy_status: '⚙️', +}; + +const BADGE_CLASS_MAP: Record = { + daily_log: styles.dailyLog, + site_visit: styles.siteVisit, + delivery: styles.delivery, + issue: styles.issue, + general_note: styles.generalNote, + work_item_status: styles.automatic, + invoice_status: styles.automatic, + milestone_delay: styles.automatic, + budget_breach: styles.automatic, + auto_reschedule: styles.automatic, + subsidy_status: styles.automatic, +}; + +export function DiaryEntryTypeBadge({ entryType, size = 'sm' }: DiaryEntryTypeBadgeProps) { + const emoji = EMOJI_MAP[entryType]; + const sizeClass = size === 'lg' ? styles.sizeLg : styles.sizeSm; + + return ( + + {emoji} + + ); +} diff --git a/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.module.css b/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.module.css new file mode 100644 index 000000000..afe0c71e0 --- /dev/null +++ b/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.module.css @@ -0,0 +1,47 @@ +.switcher { + display: flex; + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--color-border-strong); + background-color: var(--color-bg-secondary); + gap: 0; + width: fit-content; +} + +.button { + flex: 1; + padding: var(--spacing-2-5) var(--spacing-4); + background-color: transparent; + border: none; + border-right: 1px solid var(--color-border-strong); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + cursor: pointer; + transition: var(--transition-normal); + min-width: 80px; +} + +.button:last-child { + border-right: none; +} + +.button:hover:not(.active) { + background-color: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.button:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--color-focus-ring); +} + +.button.active { + background-color: var(--color-primary); + color: var(--color-primary-text); + border-right-color: var(--color-primary); +} + +.button.active:last-child { + border-right: none; +} diff --git a/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.test.tsx b/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.test.tsx new file mode 100644 index 000000000..f1c7acfb6 --- /dev/null +++ b/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.test.tsx @@ -0,0 +1,188 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { screen, render, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DiaryEntryTypeSwitcher } from './DiaryEntryTypeSwitcher.js'; + +describe('DiaryEntryTypeSwitcher', () => { + beforeEach(() => { + localStorage.setItem('theme', 'light'); + }); + + afterEach(() => { + localStorage.clear(); + }); + + const renderSwitcher = (value: 'all' | 'manual' | 'automatic' = 'all', onChange = jest.fn()) => + render(); + + // ─── Rendering ───────────────────────────────────────────────────────────── + + it('renders the radiogroup container', () => { + renderSwitcher(); + expect(screen.getByRole('radiogroup', { name: /filter entries by type/i })).toBeInTheDocument(); + }); + + it('renders three radio buttons: All, Manual, Automatic', () => { + renderSwitcher(); + expect(screen.getByTestId('type-switcher-all')).toBeInTheDocument(); + expect(screen.getByTestId('type-switcher-manual')).toBeInTheDocument(); + expect(screen.getByTestId('type-switcher-automatic')).toBeInTheDocument(); + }); + + it('renders button labels correctly', () => { + renderSwitcher(); + expect(screen.getByTestId('type-switcher-all')).toHaveTextContent('All'); + expect(screen.getByTestId('type-switcher-manual')).toHaveTextContent('Manual'); + expect(screen.getByTestId('type-switcher-automatic')).toHaveTextContent('Automatic'); + }); + + // ─── aria-checked ────────────────────────────────────────────────────────── + + it('sets aria-checked="true" on the "all" button when value is "all"', () => { + renderSwitcher('all'); + expect(screen.getByTestId('type-switcher-all')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId('type-switcher-manual')).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByTestId('type-switcher-automatic')).toHaveAttribute('aria-checked', 'false'); + }); + + it('sets aria-checked="true" on the "manual" button when value is "manual"', () => { + renderSwitcher('manual'); + expect(screen.getByTestId('type-switcher-all')).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByTestId('type-switcher-manual')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId('type-switcher-automatic')).toHaveAttribute('aria-checked', 'false'); + }); + + it('sets aria-checked="true" on the "automatic" button when value is "automatic"', () => { + renderSwitcher('automatic'); + expect(screen.getByTestId('type-switcher-all')).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByTestId('type-switcher-manual')).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByTestId('type-switcher-automatic')).toHaveAttribute('aria-checked', 'true'); + }); + + // ─── Click behaviour ─────────────────────────────────────────────────────── + + it('calls onChange with "all" when the All button is clicked', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderSwitcher('manual', onChange); + + await user.click(screen.getByTestId('type-switcher-all')); + + expect(onChange).toHaveBeenCalledWith('all'); + }); + + it('calls onChange with "manual" when the Manual button is clicked', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderSwitcher('all', onChange); + + await user.click(screen.getByTestId('type-switcher-manual')); + + expect(onChange).toHaveBeenCalledWith('manual'); + }); + + it('calls onChange with "automatic" when the Automatic button is clicked', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderSwitcher('all', onChange); + + await user.click(screen.getByTestId('type-switcher-automatic')); + + expect(onChange).toHaveBeenCalledWith('automatic'); + }); + + // ─── Arrow key navigation ────────────────────────────────────────────────── + + it('calls onChange with "manual" when ArrowRight is pressed while "all" is active', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + const radiogroup = container.querySelector('[role="radiogroup"]')!; + + fireEvent.keyDown(radiogroup, { key: 'ArrowRight' }); + + expect(onChange).toHaveBeenCalledWith('manual'); + }); + + it('calls onChange with "all" when ArrowLeft is pressed while "manual" is active', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + const radiogroup = container.querySelector('[role="radiogroup"]')!; + + fireEvent.keyDown(radiogroup, { key: 'ArrowLeft' }); + + expect(onChange).toHaveBeenCalledWith('all'); + }); + + it('calls onChange with "automatic" when ArrowRight is pressed while "manual" is active', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + const radiogroup = container.querySelector('[role="radiogroup"]')!; + + fireEvent.keyDown(radiogroup, { key: 'ArrowRight' }); + + expect(onChange).toHaveBeenCalledWith('automatic'); + }); + + it('does not call onChange when ArrowRight is pressed on the last option ("automatic")', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + const radiogroup = container.querySelector('[role="radiogroup"]')!; + + fireEvent.keyDown(radiogroup, { key: 'ArrowRight' }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not call onChange when ArrowLeft is pressed on the first option ("all")', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + const radiogroup = container.querySelector('[role="radiogroup"]')!; + + fireEvent.keyDown(radiogroup, { key: 'ArrowLeft' }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not call onChange for irrelevant key presses', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + const radiogroup = container.querySelector('[role="radiogroup"]')!; + + fireEvent.keyDown(radiogroup, { key: 'Enter' }); + fireEvent.keyDown(radiogroup, { key: ' ' }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + // ─── Active class ────────────────────────────────────────────────────────── + + it('applies "active" class to the currently selected button', () => { + renderSwitcher('manual'); + const manualBtn = screen.getByTestId('type-switcher-manual'); + expect(manualBtn.getAttribute('class') ?? '').toContain('active'); + }); + + it('does not apply "active" class to unselected buttons', () => { + renderSwitcher('manual'); + const allBtn = screen.getByTestId('type-switcher-all'); + const automaticBtn = screen.getByTestId('type-switcher-automatic'); + // The "active" class should only appear once (on the selected button) + expect(allBtn.getAttribute('class') ?? '').not.toContain('active'); + expect(automaticBtn.getAttribute('class') ?? '').not.toContain('active'); + }); +}); diff --git a/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.tsx b/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.tsx new file mode 100644 index 000000000..53f428ce6 --- /dev/null +++ b/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.tsx @@ -0,0 +1,62 @@ +import styles from './DiaryEntryTypeSwitcher.module.css'; + +type FilterMode = 'all' | 'manual' | 'automatic'; + +interface DiaryEntryTypeSwitcherProps { + value: FilterMode; + onChange: (mode: FilterMode) => void; +} + +export function DiaryEntryTypeSwitcher({ value, onChange }: DiaryEntryTypeSwitcherProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + e.preventDefault(); + const modes: FilterMode[] = ['all', 'manual', 'automatic']; + const currentIndex = modes.indexOf(value); + const newIndex = e.key === 'ArrowLeft' ? currentIndex - 1 : currentIndex + 1; + if (newIndex >= 0 && newIndex < modes.length) { + onChange(modes[newIndex]); + } + } + }; + + return ( +
+ + + +
+ ); +} diff --git a/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.module.css b/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.module.css new file mode 100644 index 000000000..28ef9afea --- /dev/null +++ b/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.module.css @@ -0,0 +1,149 @@ +.filterBar { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin-bottom: var(--spacing-6); +} + +.mobileToggle { + display: none; + width: 100%; + padding: var(--spacing-3) var(--spacing-4); + background-color: var(--color-bg-tertiary); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + cursor: pointer; + transition: var(--transition-normal); + text-align: left; + position: relative; +} + +.mobileToggle:hover { + background-color: var(--color-bg-hover); +} + +.badge { + display: inline-block; + margin-left: var(--spacing-2); + padding: var(--spacing-0-5) var(--spacing-2); + background-color: var(--color-primary); + color: var(--color-primary-text); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); +} + +.filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-4); +} + +.filterGroup { + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +.label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); +} + +.typeChips { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); +} + +.typeChip { + padding: var(--spacing-1) var(--spacing-3); + background-color: var(--color-bg-tertiary); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + cursor: pointer; + transition: var(--transition-normal); + white-space: nowrap; +} + +.typeChip:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.typeChip:focus-visible { + outline: none; + box-shadow: var(--shadow-focus-subtle); +} + +.typeChipActive { + background-color: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-primary-text); +} + +.clearButton { + padding: var(--spacing-2) var(--spacing-3); + background-color: transparent; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + cursor: pointer; + transition: var(--transition-normal); +} + +.clearButton:hover { + background-color: var(--color-danger-bg); + border-color: var(--color-danger-border); + color: var(--color-danger); +} + +.clearButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus-danger); +} + +/* Mobile styles */ +@media (max-width: 767px) { + .mobileToggle { + display: block; + } + + .filters { + display: none; + grid-template-columns: 1fr; + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-top: none; + border-radius: 0 0 var(--radius-md) var(--radius-md); + padding: var(--spacing-4); + margin-top: -1px; + z-index: var(--z-dropdown); + box-shadow: var(--shadow-md); + } + + .filtersOpen { + display: grid; + } + + .typeChips { + flex-direction: column; + gap: var(--spacing-2); + } + + .typeChip { + width: 100%; + } +} diff --git a/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.test.tsx b/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.test.tsx new file mode 100644 index 000000000..59849efd9 --- /dev/null +++ b/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.test.tsx @@ -0,0 +1,221 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { DiaryEntryType } from '@cornerstone/shared'; +import { DiaryFilterBar } from './DiaryFilterBar.js'; + +describe('DiaryFilterBar', () => { + const defaultProps = { + searchQuery: '', + onSearchChange: jest.fn<(q: string) => void>(), + dateFrom: '', + onDateFromChange: jest.fn<(d: string) => void>(), + dateTo: '', + onDateToChange: jest.fn<(d: string) => void>(), + activeTypes: [] as DiaryEntryType[], + onTypesChange: jest.fn<(types: DiaryEntryType[]) => void>(), + onClearAll: jest.fn<() => void>(), + }; + + beforeEach(() => { + localStorage.setItem('theme', 'light'); + jest.clearAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + const renderFilterBar = (overrides: Partial = {}) => + render(); + + // ─── Rendering ───────────────────────────────────────────────────────────── + + it('renders the filter bar container', () => { + renderFilterBar(); + expect(screen.getByTestId('diary-filter-bar')).toBeInTheDocument(); + }); + + it('renders the search input', () => { + renderFilterBar(); + expect(screen.getByTestId('diary-search-input')).toBeInTheDocument(); + }); + + it('renders the date-from input', () => { + renderFilterBar(); + expect(screen.getByTestId('diary-date-from')).toBeInTheDocument(); + }); + + it('renders the date-to input', () => { + renderFilterBar(); + expect(screen.getByTestId('diary-date-to')).toBeInTheDocument(); + }); + + it('renders type filter chips for all entry types', () => { + renderFilterBar(); + const expectedTypes: DiaryEntryType[] = [ + 'daily_log', 'site_visit', 'delivery', 'issue', 'general_note', + 'work_item_status', 'invoice_status', 'milestone_delay', 'budget_breach', + 'auto_reschedule', 'subsidy_status', + ]; + for (const type of expectedTypes) { + expect(screen.getByTestId(`type-filter-${type}`)).toBeInTheDocument(); + } + }); + + // ─── Type chip interaction ───────────────────────────────────────────────── + + it('calls onTypesChange with toggled type when inactive chip is clicked', async () => { + const user = userEvent.setup(); + const onTypesChange = jest.fn<(types: DiaryEntryType[]) => void>(); + renderFilterBar({ activeTypes: [], onTypesChange }); + + await user.click(screen.getByTestId('type-filter-daily_log')); + + expect(onTypesChange).toHaveBeenCalledWith(['daily_log']); + }); + + it('calls onTypesChange without the type when active chip is clicked', async () => { + const user = userEvent.setup(); + const onTypesChange = jest.fn<(types: DiaryEntryType[]) => void>(); + renderFilterBar({ activeTypes: ['daily_log', 'issue'], onTypesChange }); + + await user.click(screen.getByTestId('type-filter-daily_log')); + + expect(onTypesChange).toHaveBeenCalledWith(['issue']); + }); + + it('sets aria-pressed="true" on active type chips', () => { + renderFilterBar({ activeTypes: ['site_visit'] }); + const chip = screen.getByTestId('type-filter-site_visit'); + expect(chip).toHaveAttribute('aria-pressed', 'true'); + }); + + it('sets aria-pressed="false" on inactive type chips', () => { + renderFilterBar({ activeTypes: [] }); + const chip = screen.getByTestId('type-filter-daily_log'); + expect(chip).toHaveAttribute('aria-pressed', 'false'); + }); + + // ─── Search input ────────────────────────────────────────────────────────── + + it('calls onSearchChange when search input value changes', async () => { + const user = userEvent.setup(); + const onSearchChange = jest.fn<(q: string) => void>(); + renderFilterBar({ onSearchChange }); + + const searchInput = screen.getByTestId('diary-search-input'); + await user.type(searchInput, 'concrete'); + + expect(onSearchChange).toHaveBeenCalled(); + // Last call should have the final value + const calls = onSearchChange.mock.calls; + expect(calls[calls.length - 1][0]).toBe('e'); // last character typed + }); + + it('shows the current search query value in the input', () => { + renderFilterBar({ searchQuery: 'foundation work' }); + const input = screen.getByTestId('diary-search-input') as HTMLInputElement; + expect(input.value).toBe('foundation work'); + }); + + // ─── Date inputs ─────────────────────────────────────────────────────────── + + it('calls onDateFromChange when date-from input changes', async () => { + const user = userEvent.setup(); + const onDateFromChange = jest.fn<(d: string) => void>(); + renderFilterBar({ onDateFromChange }); + + const dateFrom = screen.getByTestId('diary-date-from'); + await user.type(dateFrom, '2026-03-01'); + + expect(onDateFromChange).toHaveBeenCalled(); + }); + + it('calls onDateToChange when date-to input changes', async () => { + const user = userEvent.setup(); + const onDateToChange = jest.fn<(d: string) => void>(); + renderFilterBar({ onDateToChange }); + + const dateTo = screen.getByTestId('diary-date-to'); + await user.type(dateTo, '2026-03-31'); + + expect(onDateToChange).toHaveBeenCalled(); + }); + + it('shows the current dateFrom value in the input', () => { + renderFilterBar({ dateFrom: '2026-03-01' }); + const input = screen.getByTestId('diary-date-from') as HTMLInputElement; + expect(input.value).toBe('2026-03-01'); + }); + + it('shows the current dateTo value in the input', () => { + renderFilterBar({ dateTo: '2026-03-31' }); + const input = screen.getByTestId('diary-date-to') as HTMLInputElement; + expect(input.value).toBe('2026-03-31'); + }); + + // ─── Active filter count badge ───────────────────────────────────────────── + + it('shows filter count badge when search is active', () => { + renderFilterBar({ searchQuery: 'test' }); + // The mobile toggle shows the badge + const toggleButton = screen.getByRole('button', { name: /toggle filters/i }); + expect(toggleButton.textContent).toContain('1'); + }); + + it('shows filter count badge when dateFrom is active', () => { + renderFilterBar({ dateFrom: '2026-03-01' }); + const toggleButton = screen.getByRole('button', { name: /toggle filters/i }); + expect(toggleButton.textContent).toContain('1'); + }); + + it('does not show filter count when no filters are active', () => { + renderFilterBar({ searchQuery: '', dateFrom: '', dateTo: '', activeTypes: [] }); + const toggleButton = screen.getByRole('button', { name: /toggle filters/i }); + // Badge should not be rendered when filterCount === 0 + // The text will be "🔍 Filters" without a number badge + expect(toggleButton.textContent).not.toMatch(/[1-9]/); + }); + + // ─── Clear all button ────────────────────────────────────────────────────── + + it('shows clear all button when there are active filters', () => { + renderFilterBar({ searchQuery: 'test' }); + expect(screen.getByTestId('clear-filters-button')).toBeInTheDocument(); + }); + + it('does not show clear all button when no filters are active', () => { + renderFilterBar({ searchQuery: '', dateFrom: '', dateTo: '', activeTypes: [] }); + expect(screen.queryByTestId('clear-filters-button')).not.toBeInTheDocument(); + }); + + it('calls onClearAll when clear all button is clicked', async () => { + const user = userEvent.setup(); + const onClearAll = jest.fn<() => void>(); + renderFilterBar({ searchQuery: 'test', onClearAll }); + + await user.click(screen.getByTestId('clear-filters-button')); + + expect(onClearAll).toHaveBeenCalledTimes(1); + }); + + // ─── Type chips group ────────────────────────────────────────────────────── + + it('renders the type chips group with role="group"', () => { + renderFilterBar(); + expect(screen.getByRole('group', { name: /filter by entry type/i })).toBeInTheDocument(); + }); + + it('active chip has different class to indicate active state', () => { + renderFilterBar({ activeTypes: ['issue'] }); + const activeChip = screen.getByTestId('type-filter-issue'); + const inactiveChip = screen.getByTestId('type-filter-daily_log'); + // CSS modules proxy maps class names to themselves + expect(activeChip.getAttribute('class') ?? '').toContain('typeChipActive'); + expect(inactiveChip.getAttribute('class') ?? '').not.toContain('typeChipActive'); + }); +}); diff --git a/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.tsx b/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.tsx new file mode 100644 index 000000000..abacb3577 --- /dev/null +++ b/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import type { DiaryEntryType } from '@cornerstone/shared'; +import shared from '../../../styles/shared.module.css'; +import styles from './DiaryFilterBar.module.css'; + +interface DiaryFilterBarProps { + searchQuery: string; + onSearchChange: (query: string) => void; + dateFrom: string; + onDateFromChange: (date: string) => void; + dateTo: string; + onDateToChange: (date: string) => void; + activeTypes: DiaryEntryType[]; + onTypesChange: (types: DiaryEntryType[]) => void; + onClearAll: () => void; + isCollapsed?: boolean; +} + +const ALL_ENTRY_TYPES: DiaryEntryType[] = [ + 'daily_log', + 'site_visit', + 'delivery', + 'issue', + 'general_note', + 'work_item_status', + 'invoice_status', + 'milestone_delay', + 'budget_breach', + 'auto_reschedule', + 'subsidy_status', +]; + +const TYPE_LABELS: Record = { + daily_log: 'Daily Log', + site_visit: 'Site Visit', + delivery: 'Delivery', + issue: 'Issue', + general_note: 'Note', + work_item_status: 'Work Item', + invoice_status: 'Invoice', + milestone_delay: 'Milestone', + budget_breach: 'Budget', + auto_reschedule: 'Schedule', + subsidy_status: 'Subsidy', +}; + +export function DiaryFilterBar({ + searchQuery, + onSearchChange, + dateFrom, + onDateFromChange, + dateTo, + onDateToChange, + activeTypes, + onTypesChange, + onClearAll, + isCollapsed = false, +}: DiaryFilterBarProps) { + const [isMobileOpen, setIsMobileOpen] = useState(false); + + const handleTypeToggle = (type: DiaryEntryType) => { + if (activeTypes.includes(type)) { + onTypesChange(activeTypes.filter((t) => t !== type)); + } else { + onTypesChange([...activeTypes, type]); + } + }; + + const filterCount = [ + searchQuery ? 1 : 0, + dateFrom ? 1 : 0, + dateTo ? 1 : 0, + activeTypes.length < ALL_ENTRY_TYPES.length && activeTypes.length > 0 ? 1 : 0, + ].reduce((a, b) => a + b, 0); + + const mobileToggleClass = [styles.mobileToggle, isMobileOpen && styles.mobileToggleOpen] + .filter(Boolean) + .join(' '); + + return ( +
+ {/* Mobile toggle button */} + + + {/* Filter content */} +
+ {/* Search input */} +
+ + onSearchChange(e.target.value)} + data-testid="diary-search-input" + /> +
+ + {/* Date range */} +
+ + onDateFromChange(e.target.value)} + data-testid="diary-date-from" + /> +
+ +
+ + onDateToChange(e.target.value)} + data-testid="diary-date-to" + /> +
+ + {/* Entry type filter chips */} +
+ +
+ {ALL_ENTRY_TYPES.map((type) => ( + + ))} +
+
+ + {/* Clear all button */} + {filterCount > 0 && ( + + )} +
+
+ ); +} diff --git a/client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.module.css b/client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.module.css new file mode 100644 index 000000000..6e1ba585d --- /dev/null +++ b/client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.module.css @@ -0,0 +1,24 @@ +.metadata { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + align-items: center; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.item { + display: inline-block; + white-space: nowrap; +} + +.confirmed { + color: var(--color-success); + font-weight: var(--font-weight-medium); +} + +.autoSummary { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + font-style: italic; +} diff --git a/client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.tsx b/client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.tsx new file mode 100644 index 000000000..271c093d1 --- /dev/null +++ b/client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.tsx @@ -0,0 +1,102 @@ +import type { + DiaryEntryType, + DiaryEntrySummary, + DailyLogMetadata, + SiteVisitMetadata, + DeliveryMetadata, + IssueMetadata, +} from '@cornerstone/shared'; +import { DiaryOutcomeBadge } from '../DiaryOutcomeBadge/DiaryOutcomeBadge.js'; +import { DiarySeverityBadge } from '../DiarySeverityBadge/DiarySeverityBadge.js'; +import styles from './DiaryMetadataSummary.module.css'; + +interface DiaryMetadataSummaryProps { + entryType: DiaryEntryType; + metadata: unknown; +} + +const WEATHER_EMOJI: Record = { + sunny: '☀️', + cloudy: '☁️', + rainy: '🌧️', + snowy: '❄️', + stormy: '⛈️', + other: '🌡️', +}; + +export function DiaryMetadataSummary({ entryType, metadata }: DiaryMetadataSummaryProps) { + if (entryType === 'daily_log' && metadata) { + const m = metadata as DailyLogMetadata; + return ( +
+ {m.weather && ( + + {WEATHER_EMOJI[m.weather] || '🌡️'} {m.weather} + + )} + {m.workersOnSite !== undefined && m.workersOnSite !== null && ( + {m.workersOnSite} workers + )} +
+ ); + } + + if (entryType === 'site_visit' && metadata) { + const m = metadata as SiteVisitMetadata; + return ( +
+ {m.outcome && } + {m.inspectorName && {m.inspectorName}} +
+ ); + } + + if (entryType === 'delivery' && metadata) { + const m = metadata as DeliveryMetadata; + return ( +
+ {m.materials && m.materials.length > 0 && ( + {m.materials.length} materials + )} + {m.deliveryConfirmed !== undefined && ( + + {m.deliveryConfirmed ? '✓ Confirmed' : '⏳ Pending'} + + )} +
+ ); + } + + if (entryType === 'issue' && metadata) { + const m = metadata as IssueMetadata; + return ( +
+ {m.severity && } + {m.resolutionStatus && ( + + {m.resolutionStatus === 'open' + ? '🔴 Open' + : m.resolutionStatus === 'in_progress' + ? '🟡 In Progress' + : '✅ Resolved'} + + )} +
+ ); + } + + if (entryType.startsWith('work_item_') || entryType.startsWith('invoice_') || + entryType.startsWith('milestone_') || entryType.startsWith('budget_') || + entryType.startsWith('auto_') || entryType.startsWith('subsidy_')) { + // Automatic entry type + if (metadata && typeof metadata === 'object' && 'changeSummary' in metadata && metadata.changeSummary) { + return ( + + {String((metadata as Record).changeSummary)} + + ); + } + } + + return null; +} diff --git a/client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.module.css b/client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.module.css new file mode 100644 index 000000000..6de3a32b8 --- /dev/null +++ b/client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.module.css @@ -0,0 +1,27 @@ +.badge { + display: inline-block; + padding: var(--spacing-1) var(--spacing-3); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + border: none; + cursor: default; +} + +/* Pass (green) */ +.pass { + background-color: var(--color-diary-outcome-pass-bg); + color: var(--color-diary-outcome-pass-text); +} + +/* Fail (red) */ +.fail { + background-color: var(--color-diary-outcome-fail-bg); + color: var(--color-diary-outcome-fail-text); +} + +/* Conditional (amber) */ +.conditional { + background-color: var(--color-diary-outcome-conditional-bg); + color: var(--color-diary-outcome-conditional-text); +} diff --git a/client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.test.tsx b/client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.test.tsx new file mode 100644 index 000000000..599792f99 --- /dev/null +++ b/client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.test.tsx @@ -0,0 +1,67 @@ +/** + * @jest-environment jsdom + */ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { screen, render } from '@testing-library/react'; +import { DiaryOutcomeBadge } from './DiaryOutcomeBadge.js'; + +describe('DiaryOutcomeBadge', () => { + beforeEach(() => { + localStorage.setItem('theme', 'light'); + }); + + afterEach(() => { + localStorage.clear(); + }); + + // ─── Labels ──────────────────────────────────────────────────────────────── + + it('renders "Pass" label for pass outcome', () => { + render(); + expect(screen.getByTestId('outcome-pass')).toHaveTextContent('Pass'); + }); + + it('renders "Fail" label for fail outcome', () => { + render(); + expect(screen.getByTestId('outcome-fail')).toHaveTextContent('Fail'); + }); + + it('renders "Conditional" label for conditional outcome', () => { + render(); + expect(screen.getByTestId('outcome-conditional')).toHaveTextContent('Conditional'); + }); + + // ─── CSS classes ─────────────────────────────────────────────────────────── + + it('applies "pass" CSS class for pass outcome', () => { + render(); + const badge = screen.getByTestId('outcome-pass'); + expect(badge.getAttribute('class') ?? '').toContain('pass'); + }); + + it('applies "fail" CSS class for fail outcome', () => { + render(); + const badge = screen.getByTestId('outcome-fail'); + expect(badge.getAttribute('class') ?? '').toContain('fail'); + }); + + it('applies "conditional" CSS class for conditional outcome', () => { + render(); + const badge = screen.getByTestId('outcome-conditional'); + expect(badge.getAttribute('class') ?? '').toContain('conditional'); + }); + + it('always applies the base "badge" class', () => { + render(); + const badge = screen.getByTestId('outcome-pass'); + expect(badge.getAttribute('class') ?? '').toContain('badge'); + }); + + // ─── data-testid ─────────────────────────────────────────────────────────── + + it('renders as a span element', () => { + render(); + const badge = screen.getByTestId('outcome-pass'); + expect(badge.tagName.toLowerCase()).toBe('span'); + }); +}); diff --git a/client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.tsx b/client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.tsx new file mode 100644 index 000000000..22854ae35 --- /dev/null +++ b/client/src/components/diary/DiaryOutcomeBadge/DiaryOutcomeBadge.tsx @@ -0,0 +1,20 @@ +import type { DiaryInspectionOutcome } from '@cornerstone/shared'; +import styles from './DiaryOutcomeBadge.module.css'; + +interface DiaryOutcomeBadgeProps { + outcome: DiaryInspectionOutcome; +} + +const OUTCOME_LABELS: Record = { + pass: 'Pass', + fail: 'Fail', + conditional: 'Conditional', +}; + +export function DiaryOutcomeBadge({ outcome }: DiaryOutcomeBadgeProps) { + return ( + + {OUTCOME_LABELS[outcome]} + + ); +} diff --git a/client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.module.css b/client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.module.css new file mode 100644 index 000000000..45609996c --- /dev/null +++ b/client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.module.css @@ -0,0 +1,33 @@ +.badge { + display: inline-block; + padding: var(--spacing-1) var(--spacing-3); + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + border: none; + cursor: default; +} + +/* Low (green) */ +.low { + background-color: var(--color-diary-severity-low-bg); + color: var(--color-diary-severity-low-text); +} + +/* Medium (amber) */ +.medium { + background-color: var(--color-diary-severity-medium-bg); + color: var(--color-diary-severity-medium-text); +} + +/* High (orange) */ +.high { + background-color: var(--color-diary-severity-high-bg); + color: var(--color-diary-severity-high-text); +} + +/* Critical (red) */ +.critical { + background-color: var(--color-diary-severity-critical-bg); + color: var(--color-diary-severity-critical-text); +} diff --git a/client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.test.tsx b/client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.test.tsx new file mode 100644 index 000000000..0579c9d2f --- /dev/null +++ b/client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.test.tsx @@ -0,0 +1,78 @@ +/** + * @jest-environment jsdom + */ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { screen, render } from '@testing-library/react'; +import { DiarySeverityBadge } from './DiarySeverityBadge.js'; + +describe('DiarySeverityBadge', () => { + beforeEach(() => { + localStorage.setItem('theme', 'light'); + }); + + afterEach(() => { + localStorage.clear(); + }); + + // ─── Labels ──────────────────────────────────────────────────────────────── + + it('renders "Low" label for low severity', () => { + render(); + expect(screen.getByTestId('severity-low')).toHaveTextContent('Low'); + }); + + it('renders "Medium" label for medium severity', () => { + render(); + expect(screen.getByTestId('severity-medium')).toHaveTextContent('Medium'); + }); + + it('renders "High" label for high severity', () => { + render(); + expect(screen.getByTestId('severity-high')).toHaveTextContent('High'); + }); + + it('renders "Critical" label for critical severity', () => { + render(); + expect(screen.getByTestId('severity-critical')).toHaveTextContent('Critical'); + }); + + // ─── CSS classes ─────────────────────────────────────────────────────────── + + it('applies "low" CSS class for low severity', () => { + render(); + const badge = screen.getByTestId('severity-low'); + expect(badge.getAttribute('class') ?? '').toContain('low'); + }); + + it('applies "medium" CSS class for medium severity', () => { + render(); + const badge = screen.getByTestId('severity-medium'); + expect(badge.getAttribute('class') ?? '').toContain('medium'); + }); + + it('applies "high" CSS class for high severity', () => { + render(); + const badge = screen.getByTestId('severity-high'); + expect(badge.getAttribute('class') ?? '').toContain('high'); + }); + + it('applies "critical" CSS class for critical severity', () => { + render(); + const badge = screen.getByTestId('severity-critical'); + expect(badge.getAttribute('class') ?? '').toContain('critical'); + }); + + it('always applies the base "badge" class', () => { + render(); + const badge = screen.getByTestId('severity-high'); + expect(badge.getAttribute('class') ?? '').toContain('badge'); + }); + + // ─── Element type ────────────────────────────────────────────────────────── + + it('renders as a span element', () => { + render(); + const badge = screen.getByTestId('severity-critical'); + expect(badge.tagName.toLowerCase()).toBe('span'); + }); +}); diff --git a/client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.tsx b/client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.tsx new file mode 100644 index 000000000..7a2a9e1ac --- /dev/null +++ b/client/src/components/diary/DiarySeverityBadge/DiarySeverityBadge.tsx @@ -0,0 +1,21 @@ +import type { DiaryIssueSeverity } from '@cornerstone/shared'; +import styles from './DiarySeverityBadge.module.css'; + +interface DiarySeverityBadgeProps { + severity: DiaryIssueSeverity; +} + +const SEVERITY_LABELS: Record = { + low: 'Low', + medium: 'Medium', + high: 'High', + critical: 'Critical', +}; + +export function DiarySeverityBadge({ severity }: DiarySeverityBadgeProps) { + return ( + + {SEVERITY_LABELS[severity]} + + ); +} diff --git a/client/src/lib/diaryApi.test.ts b/client/src/lib/diaryApi.test.ts new file mode 100644 index 000000000..dba38aaf2 --- /dev/null +++ b/client/src/lib/diaryApi.test.ts @@ -0,0 +1,399 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + listDiaryEntries, + getDiaryEntry, + createDiaryEntry, + updateDiaryEntry, + deleteDiaryEntry, +} from './diaryApi.js'; +import type { DiaryEntryListResponse, DiaryEntryDetail } from '@cornerstone/shared'; + +describe('diaryApi', () => { + let mockFetch: jest.MockedFunction; + + const baseSummary = { + id: 'de-1', + entryType: 'daily_log' as const, + entryDate: '2026-03-14', + title: 'Test Entry', + body: 'Test body content', + metadata: null, + isAutomatic: false, + sourceEntityType: null, + sourceEntityId: null, + photoCount: 0, + createdBy: { id: 'user-1', displayName: 'Alice' }, + createdAt: '2026-03-14T09:00:00.000Z', + updatedAt: '2026-03-14T09:00:00.000Z', + }; + + const mockDetail: DiaryEntryDetail = { ...baseSummary }; + + beforeEach(() => { + mockFetch = jest.fn(); + globalThis.fetch = mockFetch; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ─── listDiaryEntries ────────────────────────────────────────────────────── + + describe('listDiaryEntries', () => { + const emptyListResponse: DiaryEntryListResponse = { + items: [], + pagination: { page: 1, pageSize: 25, totalPages: 0, totalItems: 0 }, + }; + + it('sends GET request to /api/diary-entries without params when none provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyListResponse, + } as Response); + + await listDiaryEntries(); + + expect(mockFetch).toHaveBeenCalledWith('/api/diary-entries', expect.any(Object)); + }); + + it('includes page param when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyListResponse, + } as Response); + + await listDiaryEntries({ page: 2 }); + + expect(mockFetch).toHaveBeenCalledWith('/api/diary-entries?page=2', expect.any(Object)); + }); + + it('includes pageSize param when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyListResponse, + } as Response); + + await listDiaryEntries({ pageSize: 50 }); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/diary-entries?pageSize=50', + expect.any(Object), + ); + }); + + it('includes type param when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyListResponse, + } as Response); + + await listDiaryEntries({ type: 'daily_log,issue' }); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/diary-entries?type=daily_log%2Cissue', + expect.any(Object), + ); + }); + + it('includes dateFrom param when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyListResponse, + } as Response); + + await listDiaryEntries({ dateFrom: '2026-03-01' }); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/diary-entries?dateFrom=2026-03-01', + expect.any(Object), + ); + }); + + it('includes dateTo param when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyListResponse, + } as Response); + + await listDiaryEntries({ dateTo: '2026-03-31' }); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/diary-entries?dateTo=2026-03-31', + expect.any(Object), + ); + }); + + it('includes automatic param when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyListResponse, + } as Response); + + await listDiaryEntries({ automatic: true }); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/diary-entries?automatic=true', + expect.any(Object), + ); + }); + + it('includes q param when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyListResponse, + } as Response); + + await listDiaryEntries({ q: 'foundation' }); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/diary-entries?q=foundation', + expect.any(Object), + ); + }); + + it('includes multiple params when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => emptyListResponse, + } as Response); + + await listDiaryEntries({ page: 2, type: 'daily_log,issue', q: 'concrete' }); + + const callUrl = mockFetch.mock.calls[0][0] as string; + expect(callUrl).toContain('page=2'); + expect(callUrl).toContain('type='); + expect(callUrl).toContain('q=concrete'); + }); + + it('returns the parsed list response', async () => { + const mockListResponse: DiaryEntryListResponse = { + items: [baseSummary], + pagination: { page: 1, pageSize: 25, totalPages: 1, totalItems: 1 }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockListResponse, + } as Response); + + const result = await listDiaryEntries(); + + expect(result).toEqual(mockListResponse); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe('de-1'); + }); + + it('throws when response is not OK', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: { code: 'INTERNAL_ERROR', message: 'Server error' } }), + } as Response); + + await expect(listDiaryEntries()).rejects.toThrow(); + }); + }); + + // ─── getDiaryEntry ───────────────────────────────────────────────────────── + + describe('getDiaryEntry', () => { + it('sends GET request to /api/diary-entries/:id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockDetail, + } as Response); + + await getDiaryEntry('de-abc'); + + expect(mockFetch).toHaveBeenCalledWith('/api/diary-entries/de-abc', expect.any(Object)); + }); + + it('returns the parsed diary entry detail', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockDetail, + } as Response); + + const result = await getDiaryEntry('de-1'); + + expect(result).toEqual(mockDetail); + expect(result.id).toBe('de-1'); + expect(result.entryType).toBe('daily_log'); + }); + + it('throws on 404 not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ error: { code: 'NOT_FOUND', message: 'Diary entry not found' } }), + } as Response); + + await expect(getDiaryEntry('nonexistent')).rejects.toThrow(); + }); + }); + + // ─── createDiaryEntry ────────────────────────────────────────────────────── + + describe('createDiaryEntry', () => { + it('sends POST request to /api/diary-entries with the body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => mockDetail, + } as Response); + + const requestData = { + entryType: 'daily_log' as const, + entryDate: '2026-03-14', + body: 'Poured concrete foundations today.', + }; + + await createDiaryEntry(requestData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/diary-entries', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(requestData), + }), + ); + }); + + it('returns the created diary entry detail', async () => { + const newDetail: DiaryEntryDetail = { + ...baseSummary, + id: 'de-new', + title: 'New Entry', + body: 'Created today.', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => newDetail, + } as Response); + + const result = await createDiaryEntry({ + entryType: 'general_note', + entryDate: '2026-03-14', + body: 'Created today.', + }); + + expect(result).toEqual(newDetail); + expect(result.id).toBe('de-new'); + }); + + it('throws on validation error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + error: { code: 'VALIDATION_ERROR', message: 'body is required' }, + }), + } as Response); + + await expect( + createDiaryEntry({ entryType: 'daily_log', entryDate: '2026-03-14', body: '' }), + ).rejects.toThrow(); + }); + }); + + // ─── updateDiaryEntry ────────────────────────────────────────────────────── + + describe('updateDiaryEntry', () => { + it('sends PATCH request to /api/diary-entries/:id', async () => { + const updateData = { body: 'Updated body content.' }; + const updatedDetail: DiaryEntryDetail = { ...baseSummary, body: 'Updated body content.' }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => updatedDetail, + } as Response); + + await updateDiaryEntry('de-1', updateData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/diary-entries/de-1', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify(updateData), + }), + ); + }); + + it('returns the updated diary entry detail', async () => { + const updatedDetail: DiaryEntryDetail = { ...baseSummary, title: 'Updated Title' }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => updatedDetail, + } as Response); + + const result = await updateDiaryEntry('de-1', { title: 'Updated Title' }); + + expect(result.title).toBe('Updated Title'); + }); + + it('throws on 404 when entry not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ error: { code: 'NOT_FOUND', message: 'Diary entry not found' } }), + } as Response); + + await expect(updateDiaryEntry('nonexistent', { body: 'x' })).rejects.toThrow(); + }); + }); + + // ─── deleteDiaryEntry ────────────────────────────────────────────────────── + + describe('deleteDiaryEntry', () => { + it('sends DELETE request to /api/diary-entries/:id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + text: async () => '', + } as Response); + + await deleteDiaryEntry('de-1'); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/diary-entries/de-1', + expect.objectContaining({ method: 'DELETE' }), + ); + }); + + it('returns void on successful delete', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + text: async () => '', + } as Response); + + const result = await deleteDiaryEntry('de-1'); + + expect(result).toBeUndefined(); + }); + + it('throws on 404 when entry not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ error: { code: 'NOT_FOUND', message: 'Diary entry not found' } }), + } as Response); + + await expect(deleteDiaryEntry('nonexistent')).rejects.toThrow(); + }); + + it('throws on server error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: { code: 'INTERNAL_ERROR', message: 'Server error' } }), + } as Response); + + await expect(deleteDiaryEntry('de-1')).rejects.toThrow(); + }); + }); +}); diff --git a/client/src/lib/diaryApi.ts b/client/src/lib/diaryApi.ts new file mode 100644 index 000000000..ba91eee66 --- /dev/null +++ b/client/src/lib/diaryApi.ts @@ -0,0 +1,74 @@ +import { get, post, patch, del } from './apiClient.js'; +import type { + DiaryEntrySummary, + DiaryEntryDetail, + DiaryEntryListResponse, + DiaryEntryListQuery, + CreateDiaryEntryRequest, + UpdateDiaryEntryRequest, +} from '@cornerstone/shared'; + +/** + * Fetches a paginated list of diary entries with optional filters. + */ +export function listDiaryEntries(params?: DiaryEntryListQuery): Promise { + const queryParams = new URLSearchParams(); + + if (params?.page !== undefined) { + queryParams.set('page', params.page.toString()); + } + if (params?.pageSize !== undefined) { + queryParams.set('pageSize', params.pageSize.toString()); + } + if (params?.type) { + queryParams.set('type', params.type); + } + if (params?.dateFrom) { + queryParams.set('dateFrom', params.dateFrom); + } + if (params?.dateTo) { + queryParams.set('dateTo', params.dateTo); + } + if (params?.automatic !== undefined) { + queryParams.set('automatic', params.automatic.toString()); + } + if (params?.q) { + queryParams.set('q', params.q); + } + + const queryString = queryParams.toString(); + const path = queryString ? `/diary-entries?${queryString}` : '/diary-entries'; + + return get(path); +} + +/** + * Fetches a single diary entry by ID with full details. + */ +export function getDiaryEntry(id: string): Promise { + return get(`/diary-entries/${id}`); +} + +/** + * Creates a new diary entry. + */ +export function createDiaryEntry(data: CreateDiaryEntryRequest): Promise { + return post('/diary-entries', data); +} + +/** + * Updates an existing diary entry. + */ +export function updateDiaryEntry( + id: string, + data: UpdateDiaryEntryRequest, +): Promise { + return patch(`/diary-entries/${id}`, data); +} + +/** + * Deletes a diary entry. + */ +export function deleteDiaryEntry(id: string): Promise { + return del(`/diary-entries/${id}`); +} diff --git a/client/src/lib/formatters.ts b/client/src/lib/formatters.ts index f0a41a05b..338fe281e 100644 --- a/client/src/lib/formatters.ts +++ b/client/src/lib/formatters.ts @@ -59,6 +59,52 @@ export function formatDate(dateStr: string | null | undefined, fallback = '—') }); } +/** + * Format an ISO timestamp as a localized time string (HH:MM). + * + * @param timestamp - An ISO timestamp string or null/undefined. + * @param fallback - Value returned when timestamp is null/undefined. Defaults to '—'. + * @returns A localized time string, e.g. "2:45 PM", or the fallback value. + */ +export function formatTime(timestamp: string | null | undefined, fallback = '—'): string { + if (!timestamp) return fallback; + try { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + } catch { + return fallback; + } +} + +/** + * Format an ISO timestamp as a localized date and time string. + * + * @param timestamp - An ISO timestamp string or null/undefined. + * @param fallback - Value returned when timestamp is null/undefined. Defaults to '—'. + * @returns A localized date and time string, e.g. "Feb 27, 2026 at 2:45 PM", or the fallback value. + */ +export function formatDateTime(timestamp: string | null | undefined, fallback = '—'): string { + if (!timestamp) return fallback; + try { + const date = new Date(timestamp); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + ' at ' + date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + } catch { + return fallback; + } +} + /** * Computes the actual/effective duration in calendar days from start and end date strings. * For items in-progress with only a start date, computes elapsed days from start to today. diff --git a/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.module.css b/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.module.css new file mode 100644 index 000000000..0fc42de0d --- /dev/null +++ b/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.module.css @@ -0,0 +1,201 @@ +.page { + display: flex; + flex-direction: column; + gap: var(--spacing-6); + max-width: 900px; + margin: 0 auto; + padding: var(--spacing-6); +} + +.backButton { + align-self: flex-start; + padding: var(--spacing-2) var(--spacing-3); + background-color: transparent; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + cursor: pointer; + transition: var(--transition-normal); +} + +.backButton:hover { + background-color: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.backButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.card { + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-6); + box-shadow: var(--shadow-sm); +} + +.header { + display: flex; + gap: var(--spacing-4); + margin-bottom: var(--spacing-6); + align-items: flex-start; +} + +.typeBadgeContainer { + flex-shrink: 0; +} + +.headerContent { + flex: 1; + min-width: 0; +} + +.title { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-2) 0; +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-3); + font-size: var(--font-size-sm); + color: var(--color-text-muted); + align-items: center; +} + +.date { + font-weight: var(--font-weight-medium); +} + +.time { + color: var(--color-text-secondary); +} + +.author { + color: var(--color-text-secondary); +} + +.badge { + display: inline-block; + padding: var(--spacing-1) var(--spacing-2); + background-color: var(--color-bg-tertiary); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + +.body { + font-size: var(--font-size-base); + color: var(--color-text-body); + line-height: 1.6; + margin-bottom: var(--spacing-6); + white-space: pre-wrap; + word-break: break-word; +} + +.metadataSection { + margin-bottom: var(--spacing-6); + padding: var(--spacing-4); + background-color: var(--color-bg-secondary); + border-radius: var(--radius-md); + border-left: 3px solid var(--color-border-strong); +} + +.photoSection { + margin-bottom: var(--spacing-6); + padding: var(--spacing-4); + background-color: var(--color-bg-secondary); + border-radius: var(--radius-md); +} + +.photoLabel { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin: 0; +} + +.sourceSection { + margin-bottom: var(--spacing-6); + padding: var(--spacing-4); + background-color: var(--color-bg-secondary); + border-radius: var(--radius-md); +} + +.sourceLabel { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-2) 0; +} + +.sourceSection a { + display: inline-block; + padding: var(--spacing-1) var(--spacing-2); + background-color: var(--color-primary-bg); + border: 1px solid var(--color-primary); + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + color: var(--color-primary); + text-decoration: none; + transition: var(--transition-normal); +} + +.sourceSection a:hover { + background-color: var(--color-primary); + color: var(--color-primary-text); +} + +.timestamps { + padding-top: var(--spacing-4); + border-top: 1px solid var(--color-border); + display: flex; + gap: var(--spacing-6); +} + +.timestamp { + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-muted); +} + +/* Responsive */ +@media (max-width: 767px) { + .page { + padding: var(--spacing-4); + gap: var(--spacing-4); + } + + .card { + padding: var(--spacing-4); + } + + .header { + gap: var(--spacing-3); + } + + .title { + font-size: var(--font-size-xl); + } + + .meta { + gap: var(--spacing-2); + } + + .timestamps { + flex-direction: column; + gap: var(--spacing-4); + } +} diff --git a/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.test.tsx b/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.test.tsx new file mode 100644 index 000000000..8ff839b0d --- /dev/null +++ b/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.test.tsx @@ -0,0 +1,369 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { screen, waitFor, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import type * as DiaryApiTypes from '../../lib/diaryApi.js'; +import type { DiaryEntryDetail } from '@cornerstone/shared'; + +// ── API mock ────────────────────────────────────────────────────────────────── + +const mockGetDiaryEntry = jest.fn(); + +jest.unstable_mockModule('../../lib/diaryApi.js', () => ({ + getDiaryEntry: mockGetDiaryEntry, + listDiaryEntries: jest.fn(), + createDiaryEntry: jest.fn(), + updateDiaryEntry: jest.fn(), + deleteDiaryEntry: jest.fn(), +})); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const baseDetail: DiaryEntryDetail = { + id: 'de-1', + entryType: 'daily_log', + entryDate: '2026-03-14', + title: 'Foundation Work', + body: 'Poured concrete for the main foundation.', + metadata: null, + isAutomatic: false, + sourceEntityType: null, + sourceEntityId: null, + photoCount: 0, + createdBy: { id: 'user-1', displayName: 'Alice Builder' }, + createdAt: '2026-03-14T09:00:00.000Z', + updatedAt: '2026-03-14T09:00:00.000Z', +}; + +describe('DiaryEntryDetailPage', () => { + let DiaryEntryDetailPage: React.ComponentType; + + beforeEach(async () => { + localStorage.setItem('theme', 'light'); + if (!DiaryEntryDetailPage) { + const mod = await import('./DiaryEntryDetailPage.js'); + DiaryEntryDetailPage = mod.default; + } + mockGetDiaryEntry.mockReset(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + const renderDetailPage = (id = 'de-1') => + render( + + + } /> + + , + ); + + // ─── Basic rendering ──────────────────────────────────────────────────────── + + it('calls getDiaryEntry with the id from URL params', async () => { + mockGetDiaryEntry.mockResolvedValueOnce(baseDetail); + renderDetailPage('de-1'); + await waitFor(() => { + expect(mockGetDiaryEntry).toHaveBeenCalledWith('de-1'); + }); + }); + + it('shows loading indicator while fetching', () => { + mockGetDiaryEntry.mockReturnValue(new Promise(() => undefined)); + renderDetailPage(); + expect(screen.getByText(/loading entry/i)).toBeInTheDocument(); + }); + + it('renders the entry title after load', async () => { + mockGetDiaryEntry.mockResolvedValueOnce(baseDetail); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByText('Foundation Work')).toBeInTheDocument(); + }); + }); + + it('renders the entry body after load', async () => { + mockGetDiaryEntry.mockResolvedValueOnce(baseDetail); + renderDetailPage(); + await waitFor(() => { + expect( + screen.getByText('Poured concrete for the main foundation.'), + ).toBeInTheDocument(); + }); + }); + + it('renders the author display name', async () => { + mockGetDiaryEntry.mockResolvedValueOnce(baseDetail); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByText(/Alice Builder/)).toBeInTheDocument(); + }); + }); + + it('renders the type badge for the entry type', async () => { + mockGetDiaryEntry.mockResolvedValueOnce(baseDetail); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByTestId('diary-type-badge-daily_log')).toBeInTheDocument(); + }); + }); + + // ─── Back button ──────────────────────────────────────────────────────────── + + it('renders the "Back to Diary" link', async () => { + mockGetDiaryEntry.mockResolvedValueOnce(baseDetail); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByRole('link', { name: /back to diary/i })).toBeInTheDocument(); + }); + }); + + it('renders the back button (←)', async () => { + mockGetDiaryEntry.mockResolvedValueOnce(baseDetail); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument(); + }); + }); + + // ─── Type-specific metadata — daily_log ───────────────────────────────────── + + it('shows weather info from daily_log metadata', async () => { + const dailyLogEntry: DiaryEntryDetail = { + ...baseDetail, + entryType: 'daily_log', + metadata: { weather: 'sunny', workersOnSite: 5 }, + }; + mockGetDiaryEntry.mockResolvedValueOnce(dailyLogEntry); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByTestId('daily-log-metadata')).toBeInTheDocument(); + expect(screen.getByText(/sunny/i)).toBeInTheDocument(); + expect(screen.getByText(/5 workers/i)).toBeInTheDocument(); + }); + }); + + // ─── Type-specific metadata — site_visit ──────────────────────────────────── + + it('shows outcome badge for site_visit with pass outcome', async () => { + const siteVisitEntry: DiaryEntryDetail = { + ...baseDetail, + id: 'de-sv', + entryType: 'site_visit', + metadata: { inspectorName: 'Bob Inspector', outcome: 'pass' }, + }; + mockGetDiaryEntry.mockResolvedValueOnce(siteVisitEntry); + renderDetailPage('de-sv'); + await waitFor(() => { + expect(screen.getByTestId('outcome-pass')).toBeInTheDocument(); + expect(screen.getByText('Bob Inspector')).toBeInTheDocument(); + }); + }); + + it('shows outcome badge for site_visit with fail outcome', async () => { + const siteVisitEntry: DiaryEntryDetail = { + ...baseDetail, + id: 'de-sv-fail', + entryType: 'site_visit', + metadata: { outcome: 'fail' }, + }; + mockGetDiaryEntry.mockResolvedValueOnce(siteVisitEntry); + renderDetailPage('de-sv-fail'); + await waitFor(() => { + expect(screen.getByTestId('outcome-fail')).toBeInTheDocument(); + }); + }); + + it('shows outcome badge for site_visit with conditional outcome', async () => { + const siteVisitEntry: DiaryEntryDetail = { + ...baseDetail, + id: 'de-sv-cond', + entryType: 'site_visit', + metadata: { outcome: 'conditional' }, + }; + mockGetDiaryEntry.mockResolvedValueOnce(siteVisitEntry); + renderDetailPage('de-sv-cond'); + await waitFor(() => { + expect(screen.getByTestId('outcome-conditional')).toBeInTheDocument(); + }); + }); + + // ─── Type-specific metadata — issue ───────────────────────────────────────── + + it('shows severity badge for issue with high severity', async () => { + const issueEntry: DiaryEntryDetail = { + ...baseDetail, + id: 'de-iss', + entryType: 'issue', + metadata: { severity: 'high', resolutionStatus: 'open' }, + }; + mockGetDiaryEntry.mockResolvedValueOnce(issueEntry); + renderDetailPage('de-iss'); + await waitFor(() => { + expect(screen.getByTestId('severity-high')).toBeInTheDocument(); + }); + }); + + it('shows severity badge for issue with critical severity', async () => { + const issueEntry: DiaryEntryDetail = { + ...baseDetail, + id: 'de-iss-crit', + entryType: 'issue', + metadata: { severity: 'critical', resolutionStatus: 'in_progress' }, + }; + mockGetDiaryEntry.mockResolvedValueOnce(issueEntry); + renderDetailPage('de-iss-crit'); + await waitFor(() => { + expect(screen.getByTestId('severity-critical')).toBeInTheDocument(); + }); + }); + + // ─── Photo count ───────────────────────────────────────────────────────────── + + it('shows photo count section when photoCount > 0', async () => { + const entryWithPhotos: DiaryEntryDetail = { ...baseDetail, photoCount: 4 }; + mockGetDiaryEntry.mockResolvedValueOnce(entryWithPhotos); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByText(/4 photo/i)).toBeInTheDocument(); + }); + }); + + it('does not show photo count section when photoCount is 0', async () => { + mockGetDiaryEntry.mockResolvedValueOnce(baseDetail); + renderDetailPage(); + await waitFor(() => { + expect(screen.queryByText(/photo\(s\) attached/i)).not.toBeInTheDocument(); + }); + }); + + // ─── Automatic entry badge ──────────────────────────────────────────────────── + + it('shows "Automatic" badge for automatic entries', async () => { + const autoEntry: DiaryEntryDetail = { + ...baseDetail, + id: 'de-auto', + entryType: 'work_item_status', + isAutomatic: true, + createdBy: null, + }; + mockGetDiaryEntry.mockResolvedValueOnce(autoEntry); + renderDetailPage('de-auto'); + await waitFor(() => { + expect(screen.getByText('Automatic')).toBeInTheDocument(); + }); + }); + + // ─── Source entity link ─────────────────────────────────────────────────────── + + it('shows the source entity section for automatic entries', async () => { + const autoEntry: DiaryEntryDetail = { + ...baseDetail, + id: 'de-auto-link', + entryType: 'work_item_status', + isAutomatic: true, + sourceEntityType: 'work_item', + sourceEntityId: 'wi-kitchen', + createdBy: null, + }; + mockGetDiaryEntry.mockResolvedValueOnce(autoEntry); + renderDetailPage('de-auto-link'); + await waitFor(() => { + expect(screen.getByText(/related to/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Work Item' })).toHaveAttribute( + 'href', + '/project/work-items/wi-kitchen', + ); + }); + }); + + it('links to /budget/invoices/:id for invoice source entity', async () => { + const invoiceEntry: DiaryEntryDetail = { + ...baseDetail, + id: 'de-inv-link', + entryType: 'invoice_status', + isAutomatic: true, + sourceEntityType: 'invoice', + sourceEntityId: 'inv-999', + createdBy: null, + }; + mockGetDiaryEntry.mockResolvedValueOnce(invoiceEntry); + renderDetailPage('de-inv-link'); + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Invoice' })).toHaveAttribute( + 'href', + '/budget/invoices/inv-999', + ); + }); + }); + + // ─── 404 not found ─────────────────────────────────────────────────────────── + + it('shows "Diary entry not found" for a 404 error', async () => { + const { ApiClientError } = await import('../../lib/apiClient.js'); + mockGetDiaryEntry.mockRejectedValueOnce( + new ApiClientError(404, { code: 'NOT_FOUND', message: 'Diary entry not found' }), + ); + renderDetailPage('nonexistent'); + await waitFor(() => { + expect(screen.getByText('Diary entry not found')).toBeInTheDocument(); + }); + }); + + it('shows the API error message for non-404 errors', async () => { + const { ApiClientError } = await import('../../lib/apiClient.js'); + mockGetDiaryEntry.mockRejectedValueOnce( + new ApiClientError(500, { code: 'INTERNAL_ERROR', message: 'Database is down' }), + ); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByText('Database is down')).toBeInTheDocument(); + }); + }); + + it('shows generic error message for non-ApiClientError', async () => { + mockGetDiaryEntry.mockRejectedValueOnce(new Error('Network failure')); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByText(/failed to load diary entry/i)).toBeInTheDocument(); + }); + }); + + it('renders Back to Diary link in error state', async () => { + const { ApiClientError } = await import('../../lib/apiClient.js'); + mockGetDiaryEntry.mockRejectedValueOnce( + new ApiClientError(404, { code: 'NOT_FOUND', message: 'Not found' }), + ); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByRole('link', { name: /back to diary/i })).toBeInTheDocument(); + }); + }); + + // ─── Timestamps ───────────────────────────────────────────────────────────── + + it('renders the created timestamp', async () => { + mockGetDiaryEntry.mockResolvedValueOnce(baseDetail); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByText(/created/i)).toBeInTheDocument(); + }); + }); + + it('renders the updated timestamp when present', async () => { + const entryWithUpdate: DiaryEntryDetail = { + ...baseDetail, + updatedAt: '2026-03-15T10:00:00.000Z', + }; + mockGetDiaryEntry.mockResolvedValueOnce(entryWithUpdate); + renderDetailPage(); + await waitFor(() => { + expect(screen.getByText(/updated/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.tsx b/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.tsx new file mode 100644 index 000000000..8396ec59a --- /dev/null +++ b/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import type { DiaryEntryDetail } from '@cornerstone/shared'; +import { getDiaryEntry } from '../../lib/diaryApi.js'; +import { ApiClientError } from '../../lib/apiClient.js'; +import { formatDate, formatDateTime } from '../../lib/formatters.js'; +import { DiaryEntryTypeBadge } from '../../components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.js'; +import { DiaryMetadataSummary } from '../../components/diary/DiaryMetadataSummary/DiaryMetadataSummary.js'; +import shared from '../../styles/shared.module.css'; +import styles from './DiaryEntryDetailPage.module.css'; + +export default function DiaryEntryDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [entry, setEntry] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + if (!id) { + setError('Invalid diary entry ID'); + setIsLoading(false); + return; + } + + const loadEntry = async () => { + setIsLoading(true); + setError(''); + try { + const data = await getDiaryEntry(id); + setEntry(data); + } catch (err) { + if (err instanceof ApiClientError) { + if (err.statusCode === 404) { + setError('Diary entry not found'); + } else { + setError(err.error.message); + } + } else { + setError('Failed to load diary entry. Please try again.'); + } + } finally { + setIsLoading(false); + } + }; + + void loadEntry(); + }, [id]); + + if (isLoading) { + return
Loading entry...
; + } + + if (error) { + return ( +
+
{error}
+ + Back to Diary + +
+ ); + } + + if (!entry) { + return ( +
+
+

Diary entry not found.

+ + Back to Diary + +
+
+ ); + } + + return ( +
+ + +
+
+
+ +
+
+ {entry.title &&

{entry.title}

} +
+ {formatDate(entry.entryDate)} + {formatDateTime(entry.createdAt)} + {entry.createdBy && ( + by {entry.createdBy.displayName} + )} + {entry.isAutomatic && Automatic} +
+
+
+ +
{entry.body}
+ + {entry.metadata && ( +
+ +
+ )} + + {entry.photoCount > 0 && ( +
+

📷 {entry.photoCount} photo(s) attached

+
+ )} + + {entry.sourceEntityType && entry.sourceEntityId && ( +
+

Related to:

+ +
+ )} + +
+
+ Created: + {formatDateTime(entry.createdAt)} +
+ {entry.updatedAt && ( +
+ Updated: + {formatDateTime(entry.updatedAt)} +
+ )} +
+
+ + + Back to Diary + +
+ ); +} + +interface SourceEntityLinkProps { + sourceType: string; + sourceId: string; +} + +function SourceEntityLink({ sourceType, sourceId }: SourceEntityLinkProps) { + const getRoute = (): string | null => { + switch (sourceType) { + case 'work_item': + return `/project/work-items/${sourceId}`; + case 'invoice': + return `/budget/invoices/${sourceId}`; + case 'milestone': + return `/project/milestones/${sourceId}`; + case 'budget_source': + return '/budget/sources'; + case 'subsidy_program': + return '/budget/subsidies'; + default: + return null; + } + }; + + const getLabel = (): string => { + switch (sourceType) { + case 'work_item': + return 'Work Item'; + case 'invoice': + return 'Invoice'; + case 'milestone': + return 'Milestone'; + case 'budget_source': + return 'Budget Sources'; + case 'subsidy_program': + return 'Subsidy Programs'; + default: + return sourceType; + } + }; + + const route = getRoute(); + const label = getLabel(); + + if (!route) { + return {label}; + } + + return {label}; +} diff --git a/client/src/pages/DiaryPage/DiaryPage.module.css b/client/src/pages/DiaryPage/DiaryPage.module.css new file mode 100644 index 000000000..4c8706369 --- /dev/null +++ b/client/src/pages/DiaryPage/DiaryPage.module.css @@ -0,0 +1,87 @@ +.page { + display: flex; + flex-direction: column; + gap: var(--spacing-6); + max-width: 900px; + margin: 0 auto; + padding: var(--spacing-6); +} + +.header { + margin-bottom: var(--spacing-2); +} + +.title { + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: 0; +} + +.subtitle { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin: var(--spacing-2) 0 0 0; +} + +.controls { + display: flex; + align-items: center; + gap: var(--spacing-4); + justify-content: space-between; + flex-wrap: wrap; +} + +.createButton { + flex-shrink: 0; +} + +.timeline { + display: flex; + flex-direction: column; + gap: var(--spacing-6); +} + +.liveRegion { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-4); + margin-top: var(--spacing-6); +} + +.pageInfo { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + min-width: 150px; + text-align: center; +} + +/* Responsive */ +@media (max-width: 767px) { + .page { + padding: var(--spacing-4); + gap: var(--spacing-4); + } + + .title { + font-size: var(--font-size-2xl); + } + + .controls { + flex-direction: column; + align-items: stretch; + } + + .createButton { + width: 100%; + } +} diff --git a/client/src/pages/DiaryPage/DiaryPage.test.tsx b/client/src/pages/DiaryPage/DiaryPage.test.tsx new file mode 100644 index 000000000..ffef92473 --- /dev/null +++ b/client/src/pages/DiaryPage/DiaryPage.test.tsx @@ -0,0 +1,284 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { screen, waitFor, render, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import type * as DiaryApiTypes from '../../lib/diaryApi.js'; +import type { DiaryEntryListResponse, DiaryEntrySummary } from '@cornerstone/shared'; + +// ── API mock ────────────────────────────────────────────────────────────────── + +const mockListDiaryEntries = jest.fn(); + +jest.unstable_mockModule('../../lib/diaryApi.js', () => ({ + listDiaryEntries: mockListDiaryEntries, + getDiaryEntry: jest.fn(), + createDiaryEntry: jest.fn(), + updateDiaryEntry: jest.fn(), + deleteDiaryEntry: jest.fn(), +})); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeSummary(id: string, overrides: Partial = {}): DiaryEntrySummary { + return { + id, + entryType: 'daily_log', + entryDate: '2026-03-14', + title: `Entry ${id}`, + body: `Body of entry ${id}`, + metadata: null, + isAutomatic: false, + sourceEntityType: null, + sourceEntityId: null, + photoCount: 0, + createdBy: { id: 'user-1', displayName: 'Alice' }, + createdAt: '2026-03-14T09:00:00.000Z', + updatedAt: '2026-03-14T09:00:00.000Z', + ...overrides, + }; +} + +function makeListResponse(entries: DiaryEntrySummary[], totalPages = 1): DiaryEntryListResponse { + return { + items: entries, + pagination: { + page: 1, + pageSize: 25, + totalPages, + totalItems: entries.length, + }, + }; +} + +const emptyResponse = makeListResponse([]); + +describe('DiaryPage', () => { + let DiaryPage: React.ComponentType; + + beforeEach(async () => { + localStorage.setItem('theme', 'light'); + if (!DiaryPage) { + const mod = await import('./DiaryPage.js'); + DiaryPage = mod.default; + } + mockListDiaryEntries.mockReset(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + const renderPage = (initialEntries = ['/diary']) => + render( + + + , + ); + + // ─── Heading ──────────────────────────────────────────────────────────────── + + it('renders the "Construction Diary" h1 heading', async () => { + mockListDiaryEntries.mockResolvedValueOnce(emptyResponse); + renderPage(); + expect(screen.getByRole('heading', { name: 'Construction Diary', level: 1 })).toBeInTheDocument(); + }); + + it('shows the total entry count in the subtitle', async () => { + mockListDiaryEntries.mockResolvedValueOnce(makeListResponse([makeSummary('1'), makeSummary('2')])); + renderPage(); + await waitFor(() => { + expect(screen.getByText(/2 entries/i)).toBeInTheDocument(); + }); + }); + + it('uses singular "entry" when totalItems is 1', async () => { + mockListDiaryEntries.mockResolvedValueOnce(makeListResponse([makeSummary('1')])); + renderPage(); + await waitFor(() => { + expect(screen.getByText(/1 entry/i)).toBeInTheDocument(); + }); + }); + + // ─── API call on mount ─────────────────────────────────────────────────────── + + it('calls listDiaryEntries on mount', async () => { + mockListDiaryEntries.mockResolvedValueOnce(emptyResponse); + renderPage(); + await waitFor(() => { + expect(mockListDiaryEntries).toHaveBeenCalledTimes(1); + }); + }); + + // ─── Loading state ────────────────────────────────────────────────────────── + + it('shows loading indicator while fetching', () => { + // Never resolves during this check + mockListDiaryEntries.mockReturnValue(new Promise(() => undefined)); + renderPage(); + expect(screen.getByText(/loading entries/i)).toBeInTheDocument(); + }); + + // ─── Entry display and grouping ───────────────────────────────────────────── + + it('renders entry cards after successful load', async () => { + mockListDiaryEntries.mockResolvedValueOnce(makeListResponse([makeSummary('de-1')])); + renderPage(); + await waitFor(() => { + expect(screen.getByTestId('diary-card-de-1')).toBeInTheDocument(); + }); + }); + + it('groups entries under a date header', async () => { + mockListDiaryEntries.mockResolvedValueOnce( + makeListResponse([makeSummary('de-1', { entryDate: '2026-03-14' })]), + ); + renderPage(); + await waitFor(() => { + expect(screen.getByTestId('date-group-2026-03-14')).toBeInTheDocument(); + }); + }); + + it('shows multiple date groups when entries span different dates', async () => { + mockListDiaryEntries.mockResolvedValueOnce( + makeListResponse([ + makeSummary('de-1', { entryDate: '2026-03-14' }), + makeSummary('de-2', { entryDate: '2026-03-13' }), + ]), + ); + renderPage(); + await waitFor(() => { + expect(screen.getByTestId('date-group-2026-03-14')).toBeInTheDocument(); + expect(screen.getByTestId('date-group-2026-03-13')).toBeInTheDocument(); + }); + }); + + it('renders the filter bar', async () => { + mockListDiaryEntries.mockResolvedValueOnce(emptyResponse); + renderPage(); + expect(screen.getByTestId('diary-filter-bar')).toBeInTheDocument(); + }); + + it('renders the type switcher', async () => { + mockListDiaryEntries.mockResolvedValueOnce(emptyResponse); + renderPage(); + expect(screen.getByRole('radiogroup', { name: /filter entries by type/i })).toBeInTheDocument(); + }); + + // ─── Empty state ──────────────────────────────────────────────────────────── + + it('shows empty state when no entries exist', async () => { + mockListDiaryEntries.mockResolvedValueOnce(emptyResponse); + renderPage(); + await waitFor(() => { + expect(screen.getByText(/no diary entries yet/i)).toBeInTheDocument(); + }); + }); + + it('shows a CTA link to create first entry in empty state', async () => { + mockListDiaryEntries.mockResolvedValueOnce(emptyResponse); + renderPage(); + await waitFor(() => { + expect(screen.getByText(/create your first entry/i)).toBeInTheDocument(); + }); + }); + + // ─── Error state ───────────────────────────────────────────────────────────── + + it('shows an error banner when the API fails', async () => { + const { ApiClientError } = await import('../../lib/apiClient.js'); + mockListDiaryEntries.mockRejectedValueOnce( + new ApiClientError(500, { code: 'INTERNAL_ERROR', message: 'Server went down' }), + ); + renderPage(); + await waitFor(() => { + expect(screen.getByText('Server went down')).toBeInTheDocument(); + }); + }); + + it('shows generic error message when non-ApiClientError is thrown', async () => { + mockListDiaryEntries.mockRejectedValueOnce(new Error('Network error')); + renderPage(); + await waitFor(() => { + expect( + screen.getByText(/failed to load diary entries/i), + ).toBeInTheDocument(); + }); + }); + + // ─── Pagination ────────────────────────────────────────────────────────────── + + it('shows pagination controls when there are multiple pages', async () => { + mockListDiaryEntries.mockResolvedValueOnce({ + items: [makeSummary('de-1')], + pagination: { page: 1, pageSize: 25, totalPages: 3, totalItems: 60 }, + }); + renderPage(); + await waitFor(() => { + expect(screen.getByTestId('next-page-button')).toBeInTheDocument(); + expect(screen.getByTestId('prev-page-button')).toBeInTheDocument(); + }); + }); + + it('does not show pagination when there is only one page', async () => { + mockListDiaryEntries.mockResolvedValueOnce(makeListResponse([makeSummary('de-1')])); + renderPage(); + await waitFor(() => { + expect(screen.queryByTestId('next-page-button')).not.toBeInTheDocument(); + }); + }); + + it('disables the Previous button on the first page', async () => { + mockListDiaryEntries.mockResolvedValueOnce({ + items: [makeSummary('de-1')], + pagination: { page: 1, pageSize: 25, totalPages: 3, totalItems: 60 }, + }); + renderPage(); + await waitFor(() => { + expect(screen.getByTestId('prev-page-button')).toBeDisabled(); + }); + }); + + it('disables the Next button on the last page', async () => { + mockListDiaryEntries.mockResolvedValueOnce({ + items: [makeSummary('de-1')], + pagination: { page: 3, pageSize: 25, totalPages: 3, totalItems: 60 }, + }); + // Render with URL param page=3 + render( + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('next-page-button')).toBeDisabled(); + }); + }); + + // ─── Filter mode changes call API ────────────────────────────────────────── + + it('calls listDiaryEntries again when type switcher mode changes', async () => { + const user = userEvent.setup(); + mockListDiaryEntries.mockResolvedValue(emptyResponse); + + renderPage(); + await waitFor(() => expect(mockListDiaryEntries).toHaveBeenCalledTimes(1)); + + await act(async () => { + await user.click(screen.getByTestId('type-switcher-manual')); + }); + + await waitFor(() => expect(mockListDiaryEntries).toHaveBeenCalledTimes(2)); + }); + + // ─── New Entry button ───────────────────────────────────────────────────── + + it('renders a "+ New Entry" link pointing to /diary/new', async () => { + mockListDiaryEntries.mockResolvedValueOnce(emptyResponse); + renderPage(); + const newEntryLink = screen.getByRole('link', { name: /new entry/i }); + expect(newEntryLink).toHaveAttribute('href', '/diary/new'); + }); +}); diff --git a/client/src/pages/DiaryPage/DiaryPage.tsx b/client/src/pages/DiaryPage/DiaryPage.tsx new file mode 100644 index 000000000..67fa2ea8c --- /dev/null +++ b/client/src/pages/DiaryPage/DiaryPage.tsx @@ -0,0 +1,286 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useSearchParams, Link } from 'react-router-dom'; +import type { DiaryEntryType, DiaryEntrySummary } from '@cornerstone/shared'; +import { listDiaryEntries } from '../../lib/diaryApi.js'; +import { ApiClientError } from '../../lib/apiClient.js'; +import { DiaryFilterBar } from '../../components/diary/DiaryFilterBar/DiaryFilterBar.js'; +import { DiaryEntryTypeSwitcher } from '../../components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.js'; +import { DiaryDateGroup } from '../../components/diary/DiaryDateGroup/DiaryDateGroup.js'; +import shared from '../../styles/shared.module.css'; +import styles from './DiaryPage.module.css'; + +type FilterMode = 'all' | 'manual' | 'automatic'; + +interface GroupedEntries { + [date: string]: DiaryEntrySummary[]; +} + +const MANUAL_TYPES = new Set([ + 'daily_log', + 'site_visit', + 'delivery', + 'issue', + 'general_note', +] as const); + +export default function DiaryPage() { + const [searchParams, setSearchParams] = useSearchParams(); + + const [entries, setEntries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalItems, setTotalItems] = useState(0); + const pageSize = 25; + + // Filter state from URL + const searchQuery = searchParams.get('q') || ''; + const dateFrom = searchParams.get('dateFrom') || ''; + const dateTo = searchParams.get('dateTo') || ''; + const filterMode = (searchParams.get('filterMode') as FilterMode) || 'all'; + const typeFilterStr = searchParams.get('types') || ''; + const activeTypes: DiaryEntryType[] = typeFilterStr + ? (typeFilterStr.split(',') as DiaryEntryType[]) + : []; + const urlPage = parseInt(searchParams.get('page') || '1', 10); + + const [searchInput, setSearchInput] = useState(searchQuery); + const searchDebounceRef = useRef | null>(null); + const announcementRef = useRef(null); + + useEffect(() => { + if (urlPage !== currentPage) setCurrentPage(urlPage); + }, [urlPage, currentPage]); + + useEffect(() => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => { + const newParams = new URLSearchParams(searchParams); + if (searchInput) { + newParams.set('q', searchInput); + } else { + newParams.delete('q'); + } + newParams.set('page', '1'); + setSearchParams(newParams); + }, 300); + return () => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + }; + }, [searchInput, searchParams, setSearchParams]); + + useEffect(() => { + void loadEntries(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, dateFrom, dateTo, filterMode, typeFilterStr, currentPage]); + + const loadEntries = async () => { + setIsLoading(true); + setError(''); + try { + // Determine which types to query based on filter mode + let queriableTypes: DiaryEntryType[] = activeTypes; + if (filterMode === 'manual') { + queriableTypes = activeTypes.length > 0 + ? activeTypes.filter((t) => MANUAL_TYPES.has(t as any)) + : Array.from(MANUAL_TYPES) as DiaryEntryType[]; + } else if (filterMode === 'automatic') { + queriableTypes = activeTypes.length > 0 + ? activeTypes.filter((t) => !MANUAL_TYPES.has(t as any)) + : (['work_item_status', 'invoice_status', 'milestone_delay', 'budget_breach', + 'auto_reschedule', 'subsidy_status'] as const as unknown as DiaryEntryType[]); + } + + const response = await listDiaryEntries({ + page: currentPage, + pageSize, + q: searchQuery || undefined, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + type: queriableTypes.length > 0 ? queriableTypes.join(',') : undefined, + }); + + setEntries(response.items); + setTotalPages(response.pagination.totalPages); + setTotalItems(response.pagination.totalItems); + + // Announce update + if (announcementRef.current) { + announcementRef.current.textContent = `Loaded ${response.items.length} entries`; + } + } catch (err) { + if (err instanceof ApiClientError) { + setError(err.error.message); + } else { + setError('Failed to load diary entries. Please try again.'); + } + } finally { + setIsLoading(false); + } + }; + + const groupedEntries = useMemo(() => { + const grouped: GroupedEntries = {}; + entries.forEach((entry) => { + const date = entry.entryDate; + if (!grouped[date]) { + grouped[date] = []; + } + grouped[date].push(entry); + }); + return grouped; + }, [entries]); + + const handleSearchChange = (query: string) => { + setSearchInput(query); + }; + + const handleDateFromChange = (date: string) => { + const newParams = new URLSearchParams(searchParams); + if (date) { + newParams.set('dateFrom', date); + } else { + newParams.delete('dateFrom'); + } + newParams.set('page', '1'); + setSearchParams(newParams); + }; + + const handleDateToChange = (date: string) => { + const newParams = new URLSearchParams(searchParams); + if (date) { + newParams.set('dateTo', date); + } else { + newParams.delete('dateTo'); + } + newParams.set('page', '1'); + setSearchParams(newParams); + }; + + const handleTypesChange = (types: DiaryEntryType[]) => { + const newParams = new URLSearchParams(searchParams); + if (types.length > 0) { + newParams.set('types', types.join(',')); + } else { + newParams.delete('types'); + } + newParams.set('page', '1'); + setSearchParams(newParams); + }; + + const handleFilterModeChange = (mode: FilterMode) => { + const newParams = new URLSearchParams(searchParams); + newParams.set('filterMode', mode); + newParams.set('page', '1'); + setSearchParams(newParams); + }; + + const handleClearAll = () => { + setSearchInput(''); + const newParams = new URLSearchParams(); + newParams.set('filterMode', 'all'); + setSearchParams(newParams); + }; + + const handlePageChange = (page: number) => { + const newParams = new URLSearchParams(searchParams); + newParams.set('page', page.toString()); + setSearchParams(newParams); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const sortedDates = Object.keys(groupedEntries).sort().reverse(); + + return ( +
+
+

Construction Diary

+

+ {totalItems} {totalItems === 1 ? 'entry' : 'entries'} +

+
+ + {error &&
{error}
} + + + +
+ + + + New Entry + +
+ + {isLoading &&
Loading entries...
} + + {!isLoading && entries.length === 0 && ( +
+

No diary entries yet.

+ + Create your first entry + +
+ )} + + {!isLoading && entries.length > 0 && ( +
+ {sortedDates.map((date) => ( + + ))} +
+ )} + + {/* Live region for announcements */} +
+ + {/* Pagination */} + {!isLoading && totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} +
+ ); +} diff --git a/client/src/styles/tokens.css b/client/src/styles/tokens.css index 21dd7121c..6110339a6 100644 --- a/client/src/styles/tokens.css +++ b/client/src/styles/tokens.css @@ -419,6 +419,71 @@ --color-toast-error-bg: var(--color-red-50); --color-toast-error-border: var(--color-red-200); + /* ============================================================ + * LAYER 2 — DIARY ENTRY TYPE TOKENS + * ============================================================ */ + + /* Entry type: daily_log (blue) */ + --color-diary-daily-log-bg: var(--color-blue-100); + --color-diary-daily-log-text: var(--color-blue-800); + + /* Entry type: site_visit (teal) */ + --color-diary-site-visit-bg: #ccfbf1; + --color-diary-site-visit-text: #134e4a; + + /* Entry type: delivery (amber) */ + --color-diary-delivery-bg: var(--color-amber-100); + --color-diary-delivery-text: var(--color-amber-800); + + /* Entry type: issue (red) */ + --color-diary-issue-bg: var(--color-red-100); + --color-diary-issue-text: var(--color-red-700); + + /* Entry type: general_note (gray) */ + --color-diary-general-note-bg: var(--color-gray-100); + --color-diary-general-note-text: var(--color-gray-700); + + /* Automatic entry type (all auto types use this) */ + --color-diary-automatic-bg: var(--color-bg-tertiary); + --color-diary-automatic-text: var(--color-text-secondary); + --color-diary-automatic-border: var(--color-border-strong); + + /* ============================================================ + * LAYER 2 — DIARY OUTCOME BADGES + * ============================================================ */ + + /* Outcome: pass (green) */ + --color-diary-outcome-pass-bg: var(--color-green-100); + --color-diary-outcome-pass-text: var(--color-green-900); + + /* Outcome: fail (red) */ + --color-diary-outcome-fail-bg: var(--color-red-100); + --color-diary-outcome-fail-text: var(--color-red-700); + + /* Outcome: conditional (amber) */ + --color-diary-outcome-conditional-bg: var(--color-amber-100); + --color-diary-outcome-conditional-text: var(--color-amber-800); + + /* ============================================================ + * LAYER 2 — DIARY SEVERITY BADGES + * ============================================================ */ + + /* Severity: low (green) */ + --color-diary-severity-low-bg: var(--color-green-100); + --color-diary-severity-low-text: var(--color-green-900); + + /* Severity: medium (amber) */ + --color-diary-severity-medium-bg: var(--color-amber-100); + --color-diary-severity-medium-text: var(--color-amber-800); + + /* Severity: high (orange) */ + --color-diary-severity-high-bg: #fed7aa; + --color-diary-severity-high-text: #92400e; + + /* Severity: critical (red) */ + --color-diary-severity-critical-bg: var(--color-red-100); + --color-diary-severity-critical-text: var(--color-red-700); + /* ============================================================ * LAYER 2 — CALENDAR ITEM PALETTE TOKENS * 8 visually distinct, brand-compatible colors for item differentiation. @@ -653,6 +718,49 @@ --color-toast-error-bg: rgba(239, 68, 68, 0.1); --color-toast-error-border: rgba(239, 68, 68, 0.3); + /* --- Diary entry type tokens (dark mode) --- */ + --color-diary-daily-log-bg: rgba(59, 130, 246, 0.2); + --color-diary-daily-log-text: var(--color-blue-300); + + --color-diary-site-visit-bg: rgba(20, 184, 166, 0.15); + --color-diary-site-visit-text: #5eead4; + + --color-diary-delivery-bg: rgba(245, 158, 11, 0.15); + --color-diary-delivery-text: var(--color-amber-300); + + --color-diary-issue-bg: rgba(239, 68, 68, 0.15); + --color-diary-issue-text: var(--color-red-300); + + --color-diary-general-note-bg: var(--color-slate-500); + --color-diary-general-note-text: var(--color-slate-100); + + --color-diary-automatic-bg: var(--color-slate-600); + --color-diary-automatic-text: var(--color-slate-200); + --color-diary-automatic-border: var(--color-slate-500); + + /* --- Diary outcome badges (dark mode) --- */ + --color-diary-outcome-pass-bg: rgba(16, 185, 129, 0.15); + --color-diary-outcome-pass-text: var(--color-emerald-300); + + --color-diary-outcome-fail-bg: rgba(239, 68, 68, 0.15); + --color-diary-outcome-fail-text: var(--color-red-300); + + --color-diary-outcome-conditional-bg: rgba(245, 158, 11, 0.15); + --color-diary-outcome-conditional-text: var(--color-amber-300); + + /* --- Diary severity badges (dark mode) --- */ + --color-diary-severity-low-bg: rgba(16, 185, 129, 0.15); + --color-diary-severity-low-text: var(--color-emerald-300); + + --color-diary-severity-medium-bg: rgba(245, 158, 11, 0.15); + --color-diary-severity-medium-text: var(--color-amber-300); + + --color-diary-severity-high-bg: rgba(249, 115, 22, 0.2); + --color-diary-severity-high-text: #fdba74; + + --color-diary-severity-critical-bg: rgba(239, 68, 68, 0.15); + --color-diary-severity-critical-text: var(--color-red-300); + /* --- Calendar item palette (dark mode — muted semi-transparent fills) --- */ /* 1. Blue */ diff --git a/e2e/fixtures/apiHelpers.ts b/e2e/fixtures/apiHelpers.ts index a7af20fc4..88a222217 100644 --- a/e2e/fixtures/apiHelpers.ts +++ b/e2e/fixtures/apiHelpers.ts @@ -155,3 +155,27 @@ export async function createHouseholdItemViaApi( export async function deleteHouseholdItemViaApi(page: Page, id: string): Promise { await page.request.delete(`${API.householdItems}/${id}`); } + +// ───────────────────────────────────────────────────────────────────────────── +// Diary Entries +// ───────────────────────────────────────────────────────────────────────────── + +export async function createDiaryEntryViaApi( + page: Page, + data: { + entryType: 'daily_log' | 'site_visit' | 'delivery' | 'issue' | 'general_note'; + entryDate: string; + body: string; + title?: string | null; + metadata?: Record | null; + }, +): Promise { + const response = await page.request.post(API.diaryEntries, { data }); + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { id: string }; + return body.id; +} + +export async function deleteDiaryEntryViaApi(page: Page, id: string): Promise { + await page.request.delete(`${API.diaryEntries}/${id}`); +} diff --git a/e2e/fixtures/testData.ts b/e2e/fixtures/testData.ts index 6a8ea59d5..fee22507b 100644 --- a/e2e/fixtures/testData.ts +++ b/e2e/fixtures/testData.ts @@ -31,6 +31,7 @@ export const ROUTES = { householdItemsNew: '/project/household-items/new', profile: '/settings/profile', userManagement: '/settings/users', + diary: '/diary', }; export const API = { @@ -52,4 +53,5 @@ export const API = { timeline: '/api/timeline', schedule: '/api/schedule', householdItems: '/api/household-items', + diaryEntries: '/api/diary-entries', }; diff --git a/e2e/pages/DiaryEntryDetailPage.ts b/e2e/pages/DiaryEntryDetailPage.ts new file mode 100644 index 000000000..72de8d20b --- /dev/null +++ b/e2e/pages/DiaryEntryDetailPage.ts @@ -0,0 +1,152 @@ +/** + * Page Object Model for the Diary Entry Detail page (/diary/:id) + * + * The page renders: + * - A "← Back" button (type="button", class styles.backButton) that calls navigate(-1) + * - A card container with: + * - A DiaryEntryTypeBadge (size="lg") + * - An optional h1 entry title (class styles.title) — only rendered when entry.title is set + * - Meta row: formatted entry date, formatted createdAt time, author display name, + * "Automatic" badge (when isAutomatic) + * - Body text (class styles.body) + * - Optional DiaryMetadataSummary section (class styles.metadataSection) + * - Optional photo count paragraph (class styles.photoLabel) when photoCount > 0 + * - Optional source entity section with a link (class styles.sourceSection) + * - Timestamps footer (Created / Updated) + * - A "Back to Diary" link (shared.btnSecondary) navigating to /diary + * - Error state: bannerError div + "Back to Diary" link — shown when 404 or other API error + * + * Key DOM observations from source code: + * - Back button: type="button", title="Go back" — use getByTitle or getByText('← Back') + * - Entry title: only rendered if entry.title is non-null/non-empty + * - "Back to Diary" is a (anchor), not a + this.backButton = page.getByLabel('Go back'); + + // "Back to Diary" link at bottom of page — a element + this.backToDiaryLink = page.getByRole('link', { name: 'Back to Diary' }); + + // Entry title h1 (conditional — only rendered when entry.title is set) + this.entryTitle = page.locator('[class*="title"]').filter({ has: page.locator('h1') }).or( + page.getByRole('heading', { level: 1 }), + ); + + this.entryBody = page.locator('[class*="body"]').first(); + this.entryDate = page.locator('[class*="date"]').first(); + this.entryAuthor = page.locator('[class*="author"]').first(); + this.automaticBadge = page.locator('[class*="badge"]').filter({ hasText: 'Automatic' }); + + // Metadata section container + this.metadataSection = page.locator('[class*="metadataSection"]'); + + // Type-specific metadata wrappers (from DiaryMetadataSummary) + this.dailyLogMetadata = page.getByTestId('daily-log-metadata'); + this.siteVisitMetadata = page.getByTestId('site-visit-metadata'); + this.deliveryMetadata = page.getByTestId('delivery-metadata'); + this.issueMetadata = page.getByTestId('issue-metadata'); + + // Photo count section + this.photoSection = page.locator('[class*="photoSection"]'); + + // Source entity section + this.sourceSection = page.locator('[class*="sourceSection"]'); + + // Timestamps footer + this.timestamps = page.locator('[class*="timestamps"]'); + + // Error banner + this.errorBanner = page.locator('[class*="bannerError"]'); + } + + /** + * Navigate to the detail page for the given diary entry ID. + * Waits for either the back button (success) or the error banner (error state). + * No explicit timeout — uses project-level actionTimeout. + */ + async goto(id: string): Promise { + await this.page.goto(`${DIARY_ENTRY_DETAIL_ROUTE}/${id}`); + await Promise.race([ + this.backButton.waitFor({ state: 'visible' }), + this.errorBanner.waitFor({ state: 'visible' }), + ]); + } + + /** + * Get the text of the entry title heading, or null if it is not rendered. + * The title is only rendered when entry.title is non-null in the API response. + */ + async getEntryTitleText(): Promise { + try { + const h1 = this.page.getByRole('heading', { level: 1 }); + await h1.waitFor({ state: 'visible' }); + return await h1.textContent(); + } catch { + return null; + } + } + + /** + * Get the outcome badge locator for a specific inspection outcome. + * data-testid="outcome-{pass|fail|conditional}" (DiaryOutcomeBadge component) + */ + outcomeBadge(outcome: 'pass' | 'fail' | 'conditional'): Locator { + return this.page.getByTestId(`outcome-${outcome}`); + } + + /** + * Get the severity badge locator for a specific severity level. + * data-testid="severity-{low|medium|high|critical}" (DiarySeverityBadge component) + */ + severityBadge(severity: 'low' | 'medium' | 'high' | 'critical'): Locator { + return this.page.getByTestId(`severity-${severity}`); + } +} diff --git a/e2e/pages/DiaryPage.ts b/e2e/pages/DiaryPage.ts new file mode 100644 index 000000000..28c4330c3 --- /dev/null +++ b/e2e/pages/DiaryPage.ts @@ -0,0 +1,194 @@ +/** + * Page Object Model for the Construction Diary list page (/diary) + * + * The page renders: + * - A page header with h1 "Construction Diary" and a subtitle with the total entry count + * - A DiaryFilterBar with search input (data-testid="diary-search-input"), date range pickers, + * entry type chip filters, and a "Clear all" button + * - A DiaryEntryTypeSwitcher segmented control (data-testid: type-switcher-all/manual/automatic) + * - A "+ New Entry" link button navigating to /diary/new + * - A timeline of DiaryDateGroup sections (data-testid="date-group-{date}"), each containing + * DiaryEntryCard links (data-testid="diary-card-{id}") + * - An empty state (class emptyState from shared.module.css) with a "Create your first entry" link + * - A live region (role="status") that announces loaded entry count + * - Pagination: "Previous"/"Next" buttons (data-testid: prev-page-button / next-page-button) + * + * Key DOM observations from source: + * - h1 has class styles.title (CSS module), not a data-testid; use role heading + * - Empty state uses shared.emptyState CSS module class, not a data-testid + * - Date group sections: data-testid="date-group-YYYY-MM-DD" + * - Entry cards: data-testid="diary-card-{id}" (rendered as ) + * - Filter bar wrapper: data-testid="diary-filter-bar" + * - Search input: data-testid="diary-search-input" (also id="diary-search") + * - Type chips: data-testid="type-filter-{type}" + * - Clear filters: data-testid="clear-filters-button" + * - Pagination buttons: data-testid="prev-page-button" / data-testid="next-page-button" + */ + +import type { Page, Locator } from '@playwright/test'; + +export const DIARY_ROUTE = '/diary'; + +export class DiaryPage { + readonly page: Page; + + // Page header + readonly heading: Locator; + readonly subtitle: Locator; + + // Filter bar + readonly filterBar: Locator; + readonly searchInput: Locator; + readonly dateFromInput: Locator; + readonly dateToInput: Locator; + readonly clearFiltersButton: Locator; + + // Type switcher + readonly typeSwitcherAll: Locator; + readonly typeSwitcherManual: Locator; + readonly typeSwitcherAutomatic: Locator; + + // "New Entry" button + readonly newEntryButton: Locator; + + // Timeline and entry cards + readonly timeline: Locator; + + // Empty state — uses shared.emptyState CSS module; .first() to avoid strict-mode collision + // with child elements that may also carry an "emptyState" class token + readonly emptyState: Locator; + + // Error banner + readonly errorBanner: Locator; + + // Pagination + readonly prevPageButton: Locator; + readonly nextPageButton: Locator; + + constructor(page: Page) { + this.page = page; + + this.heading = page.getByRole('heading', { level: 1, name: 'Construction Diary' }); + // The subtitle is a

sibling of the heading inside the header element + this.subtitle = page.locator('[class*="subtitle"]'); + + this.filterBar = page.getByTestId('diary-filter-bar'); + this.searchInput = page.getByTestId('diary-search-input'); + this.dateFromInput = page.getByTestId('diary-date-from'); + this.dateToInput = page.getByTestId('diary-date-to'); + this.clearFiltersButton = page.getByTestId('clear-filters-button'); + + this.typeSwitcherAll = page.getByTestId('type-switcher-all'); + this.typeSwitcherManual = page.getByTestId('type-switcher-manual'); + this.typeSwitcherAutomatic = page.getByTestId('type-switcher-automatic'); + + this.newEntryButton = page.getByRole('link', { name: '+ New Entry' }); + + this.timeline = page.locator('[class*="timeline"]'); + + // Empty state — conditional render: `{!isLoading && entries.length === 0 &&

}` + // Uses shared.emptyState CSS class. Use .first() in case multiple containers appear. + this.emptyState = page.locator('[class*="emptyState"]').first(); + + this.errorBanner = page.locator('[class*="bannerError"]'); + + this.prevPageButton = page.getByTestId('prev-page-button'); + this.nextPageButton = page.getByTestId('next-page-button'); + } + + /** + * Navigate to the diary list page and wait for the heading to be visible. + * No explicit timeout — uses project-level actionTimeout. + */ + async goto(): Promise { + await this.page.goto(DIARY_ROUTE); + await this.heading.waitFor({ state: 'visible' }); + } + + /** + * Wait for the page to finish its initial data fetch. + * Races: timeline visible, empty state visible, or error banner visible. + * No explicit timeout — uses project-level actionTimeout. + */ + async waitForLoaded(): Promise { + await Promise.race([ + this.timeline.waitFor({ state: 'visible' }), + this.emptyState.waitFor({ state: 'visible' }), + this.errorBanner.waitFor({ state: 'visible' }), + ]); + } + + /** + * Get all entry card locators currently rendered in the timeline. + */ + entryCards(): Locator { + return this.page.locator('[data-testid^="diary-card-"]'); + } + + /** + * Get all date group section locators currently rendered. + */ + dateGroups(): Locator { + return this.page.locator('[data-testid^="date-group-"]'); + } + + /** + * Get the entry card for a specific entry ID. + */ + entryCard(id: string): Locator { + return this.page.getByTestId(`diary-card-${id}`); + } + + /** + * Get the type filter chip button for the given type. + */ + typeFilterChip(type: string): Locator { + return this.page.getByTestId(`type-filter-${type}`); + } + + /** + * Type a search query and wait for the debounced API response and DOM update. + * The response listener is registered BEFORE the fill action to avoid a race + * condition (debounce + API round-trip can fire and complete before the next + * line executes, especially on WebKit). + */ + async search(query: string): Promise { + const responsePromise = this.page.waitForResponse( + (resp) => resp.url().includes('/api/diary-entries') && resp.status() === 200, + { timeout: 10_000 }, + ); + await this.searchInput.scrollIntoViewIfNeeded(); + await this.searchInput.waitFor({ state: 'visible' }); + await this.searchInput.fill(query); + await responsePromise; + await this.waitForLoaded(); + } + + /** + * Clear the search input and wait for the API response and DOM update. + */ + async clearSearch(): Promise { + const responsePromise = this.page.waitForResponse( + (resp) => resp.url().includes('/api/diary-entries') && resp.status() === 200, + { timeout: 10_000 }, + ); + await this.searchInput.clear(); + await responsePromise; + await this.waitForLoaded(); + } + + /** + * Get the total entry count from the subtitle text (e.g. "42 entries"). + * Returns null if the subtitle is not visible. + */ + async getEntryCount(): Promise { + try { + await this.subtitle.waitFor({ state: 'visible' }); + const text = await this.subtitle.textContent(); + const match = text?.match(/(\d+)/); + return match ? parseInt(match[1], 10) : null; + } catch { + return null; + } + } +} diff --git a/e2e/tests/diary/diary-detail.spec.ts b/e2e/tests/diary/diary-detail.spec.ts new file mode 100644 index 000000000..9640a5c5c --- /dev/null +++ b/e2e/tests/diary/diary-detail.spec.ts @@ -0,0 +1,452 @@ +/** + * E2E tests for the Diary Entry Detail page (/diary/:id) + * + * Story #804: Diary timeline view with filtering and search + * + * Scenarios covered: + * 1. Detail page loads for a created entry — shows body text (@smoke @responsive) + * 2. "← Back" button returns to the previous page (/diary) + * 3. "Back to Diary" link at bottom navigates to /diary + * 4. daily_log metadata section renders weather and workers on-site + * 5. site_visit outcome badge renders (pass/fail/conditional) + * 6. issue severity badge renders (low/medium/high/critical) + * 7. 404 / error state shown for a non-existent entry ID + * 8. Automatic badge shown for automatic (system) entries (mock API) + * 9. Responsive — no horizontal scroll on current viewport (@responsive) + * 10. Dark mode — page renders without layout overflow (@responsive) + */ + +import { test, expect } from '../../fixtures/auth.js'; +import { DiaryEntryDetailPage } from '../../pages/DiaryEntryDetailPage.js'; +import { DiaryPage } from '../../pages/DiaryPage.js'; +import { API } from '../../fixtures/testData.js'; +import { createDiaryEntryViaApi, deleteDiaryEntryViaApi } from '../../fixtures/apiHelpers.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 1: Detail page loads for a created entry +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Page load (Scenario 1)', { tag: '@responsive' }, () => { + test( + 'Diary detail page loads and shows the entry body text', + { tag: '@smoke' }, + async ({ page, testPrefix }) => { + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + const body = `${testPrefix} detail page body text`; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body, + title: `${testPrefix} Detail Smoke Test`, + }); + + await detailPage.goto(createdId); + + // The back button is our primary "page is loaded" signal + await expect(detailPage.backButton).toBeVisible(); + + // Body text is rendered + await expect(detailPage.entryBody).toContainText(body); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }, + ); + + test('Entry title h1 is rendered when the entry has a title', async ({ + page, + testPrefix, + }) => { + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + const title = `${testPrefix} Detail Title Test`; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: 'Entry body for title test', + title, + }); + + await detailPage.goto(createdId); + + const titleText = await detailPage.getEntryTitleText(); + expect(titleText).toContain(title); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 2: "← Back" button returns to the previous page +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Back button navigation (Scenario 2)', { tag: '@responsive' }, () => { + test('"← Back" button returns to the diary list page', async ({ page, testPrefix }) => { + const diaryPage = new DiaryPage(page); + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: `${testPrefix} back button test`, + title: `${testPrefix} Back Button Test`, + }); + + // Start from the list page so navigate(-1) goes back there + await diaryPage.goto(); + await diaryPage.waitForLoaded(); + + // Navigate to the detail page by URL + await page.goto(`/diary/${createdId}`); + await detailPage.backButton.waitFor({ state: 'visible' }); + + await detailPage.backButton.click(); + + // Should return to /diary + await page.waitForURL('**/diary'); + expect(page.url()).toContain('/diary'); + // Should not be on the detail page + expect(page.url()).not.toMatch(/\/diary\/[a-zA-Z0-9-]+$/); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 3: "Back to Diary" link navigates to /diary +// ───────────────────────────────────────────────────────────────────────────── +test.describe('"Back to Diary" link (Scenario 3)', { tag: '@responsive' }, () => { + test('"Back to Diary" link at the bottom navigates to /diary', async ({ + page, + testPrefix, + }) => { + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: `${testPrefix} back to diary link test`, + }); + + await detailPage.goto(createdId); + + await expect(detailPage.backToDiaryLink).toBeVisible(); + await detailPage.backToDiaryLink.click(); + + await page.waitForURL('**/diary'); + expect(page.url()).toContain('/diary'); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 4: daily_log metadata renders weather and workers +// ───────────────────────────────────────────────────────────────────────────── +test.describe('daily_log metadata (Scenario 4)', () => { + test('daily_log entry shows weather and workers-on-site in metadata summary', async ({ + page, + testPrefix, + }) => { + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'daily_log', + entryDate: '2026-03-14', + body: `${testPrefix} daily log entry`, + title: `${testPrefix} Daily Log Metadata Test`, + metadata: { + weather: 'sunny', + temperatureCelsius: 18, + workersOnSite: 5, + }, + }); + + await detailPage.goto(createdId); + + // The metadata summary section should be rendered + await expect(detailPage.dailyLogMetadata).toBeVisible(); + + // Weather and workers text should appear inside the metadata area + const metadataText = await detailPage.dailyLogMetadata.textContent(); + expect(metadataText?.toLowerCase()).toContain('sunny'); + expect(metadataText).toContain('5'); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 5: site_visit outcome badge renders +// ───────────────────────────────────────────────────────────────────────────── +test.describe('site_visit outcome badge (Scenario 5)', () => { + test('site_visit entry shows outcome badge for "pass" result', async ({ + page, + testPrefix, + }) => { + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'site_visit', + entryDate: '2026-03-14', + body: `${testPrefix} site visit entry`, + title: `${testPrefix} Site Visit Pass Test`, + metadata: { + inspectorName: 'Jane Inspector', + outcome: 'pass', + }, + }); + + await detailPage.goto(createdId); + + // site_visit metadata wrapper should be visible + await expect(detailPage.siteVisitMetadata).toBeVisible(); + + // Outcome badge with data-testid="outcome-pass" from DiaryOutcomeBadge + await expect(detailPage.outcomeBadge('pass')).toBeVisible(); + await expect(detailPage.outcomeBadge('pass')).toHaveText('Pass'); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); + + test('site_visit entry shows "Fail" outcome badge', async ({ page, testPrefix }) => { + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'site_visit', + entryDate: '2026-03-14', + body: `${testPrefix} site visit fail entry`, + metadata: { + outcome: 'fail', + }, + }); + + await detailPage.goto(createdId); + await expect(detailPage.outcomeBadge('fail')).toBeVisible(); + await expect(detailPage.outcomeBadge('fail')).toHaveText('Fail'); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 6: issue severity badge renders +// ───────────────────────────────────────────────────────────────────────────── +test.describe('issue severity badge (Scenario 6)', () => { + test('issue entry shows severity badge for "critical" severity', async ({ + page, + testPrefix, + }) => { + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'issue', + entryDate: '2026-03-14', + body: `${testPrefix} critical issue entry`, + title: `${testPrefix} Critical Issue Test`, + metadata: { + severity: 'critical', + resolutionStatus: 'open', + }, + }); + + await detailPage.goto(createdId); + + // Issue metadata wrapper should be visible + await expect(detailPage.issueMetadata).toBeVisible(); + + // Severity badge: data-testid="severity-critical" from DiarySeverityBadge + await expect(detailPage.severityBadge('critical')).toBeVisible(); + await expect(detailPage.severityBadge('critical')).toHaveText('Critical'); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); + + test('issue entry shows "High" severity badge', async ({ page, testPrefix }) => { + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'issue', + entryDate: '2026-03-14', + body: `${testPrefix} high issue entry`, + metadata: { + severity: 'high', + resolutionStatus: 'in_progress', + }, + }); + + await detailPage.goto(createdId); + await expect(detailPage.severityBadge('high')).toBeVisible(); + await expect(detailPage.severityBadge('high')).toHaveText('High'); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 7: 404 / error state for non-existent entry +// ───────────────────────────────────────────────────────────────────────────── +test.describe('404 error state (Scenario 7)', { tag: '@responsive' }, () => { + test('Navigating to a non-existent diary entry ID shows an error message', async ({ page }) => { + const detailPage = new DiaryEntryDetailPage(page); + + await detailPage.goto('00000000-0000-0000-0000-000000000000'); + + // Error banner shown + await expect(detailPage.errorBanner).toBeVisible(); + const errorText = await detailPage.errorBanner.textContent(); + expect(errorText?.toLowerCase()).toMatch(/not found|diary entry not found/); + + // "Back to Diary" link rendered in the error state + await expect(detailPage.backToDiaryLink).toBeVisible(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 8: "Automatic" badge shown for system-generated entries (mock API) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Automatic entry badge (Scenario 8)', () => { + test('Automatic system entry shows an "Automatic" badge in the detail view', async ({ + page, + }) => { + const detailPage = new DiaryEntryDetailPage(page); + const mockId = 'mock-auto-entry-001'; + + // Mock the individual entry endpoint to return an automatic entry + await page.route(`${API.diaryEntries}/${mockId}`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: mockId, + entryType: 'work_item_status', + entryDate: '2026-03-14', + title: null, + body: 'Work item "Kitchen Installation" changed status from planning to in_progress.', + metadata: { + changeSummary: 'Status changed from planning to in_progress.', + previousValue: 'planning', + newValue: 'in_progress', + }, + isAutomatic: true, + sourceEntityType: 'work_item', + sourceEntityId: 'wi-001', + photoCount: 0, + createdBy: null, + createdAt: '2026-03-14T09:00:00.000Z', + updatedAt: '2026-03-14T09:00:00.000Z', + }), + }); + } else { + await route.continue(); + } + }); + + try { + await detailPage.goto(mockId); + + // Automatic badge should be visible + await expect(detailPage.automaticBadge).toBeVisible(); + await expect(detailPage.automaticBadge).toHaveText('Automatic'); + + // Source section links to the related work item + await expect(detailPage.sourceSection).toBeVisible(); + } finally { + await page.unroute(`${API.diaryEntries}/${mockId}`); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 9: Responsive — no horizontal scroll +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Responsive layout (Scenario 9)', { tag: '@responsive' }, () => { + test('Diary detail page renders without horizontal scroll on current viewport', async ({ + page, + testPrefix, + }) => { + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: `${testPrefix} responsive detail test`, + }); + + await detailPage.goto(createdId); + + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + + expect(hasHorizontalScroll).toBe(false); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 10: Dark mode rendering +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Dark mode rendering (Scenario 10)', { tag: '@responsive' }, () => { + test('Diary detail page renders correctly in dark mode without layout overflow', async ({ + page, + testPrefix, + }) => { + const detailPage = new DiaryEntryDetailPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: `${testPrefix} dark mode detail test`, + }); + + await page.goto(`/diary/${createdId}`); + await page.evaluate(() => { + document.documentElement.setAttribute('data-theme', 'dark'); + }); + + await detailPage.backButton.waitFor({ state: 'visible' }); + + await expect(detailPage.backButton).toBeVisible(); + + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + expect(hasHorizontalScroll).toBe(false); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); +}); diff --git a/e2e/tests/diary/diary-list.spec.ts b/e2e/tests/diary/diary-list.spec.ts new file mode 100644 index 000000000..f9d5ba818 --- /dev/null +++ b/e2e/tests/diary/diary-list.spec.ts @@ -0,0 +1,580 @@ +/** + * E2E tests for the Construction Diary list page (/diary) + * + * Story #804: Diary timeline view with filtering and search + * + * Scenarios covered: + * 1. Page loads with h1 "Construction Diary" (@smoke @responsive) + * 2. Sidebar navigation to /diary works (@responsive) + * 3. Empty state when no entries exist (mock API) + * 4. Entry created via API appears in the timeline + * 5. Date grouping — entries on different dates render separate date headers + * 6. Search filter finds a specific entry + * 7. "Next" pagination button fetches page 2 (mock API) + * 8. Entry card click navigates to the detail page + * 9. Type switcher filters to manual-only entries (mock API) + * 10. Responsive — no horizontal scroll on current viewport (@responsive) + * 11. Dark mode — page renders without layout overflow + */ + +import { test, expect } from '../../fixtures/auth.js'; +import { DiaryPage, DIARY_ROUTE } from '../../pages/DiaryPage.js'; +import { API } from '../../fixtures/testData.js'; +import { createDiaryEntryViaApi, deleteDiaryEntryViaApi } from '../../fixtures/apiHelpers.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers — minimal mock entry shapes used for API route mocks +// ───────────────────────────────────────────────────────────────────────────── + +function makeMockEntry(overrides: Partial> = {}): Record { + return { + id: 'mock-entry-1', + entryType: 'general_note', + entryDate: '2026-03-14', + title: 'Mock Entry', + body: 'This is a mock diary entry body text.', + metadata: null, + isAutomatic: false, + sourceEntityType: null, + sourceEntityId: null, + photoCount: 0, + createdBy: { id: 'user-1', displayName: 'E2E Admin' }, + createdAt: '2026-03-14T10:00:00.000Z', + updatedAt: '2026-03-14T10:00:00.000Z', + ...overrides, + }; +} + +function makePaginatedResponse( + entries: Record[], + overrides: Partial<{ page: number; pageSize: number; totalItems: number; totalPages: number }> = {}, +): Record { + return { + data: entries, + pagination: { + page: 1, + pageSize: 25, + totalItems: entries.length, + totalPages: 1, + ...overrides, + }, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 1: Page loads with h1 "Construction Diary" +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Page load (Scenario 1)', { tag: '@responsive' }, () => { + test( + 'Diary list page loads with h1 "Construction Diary"', + { tag: '@smoke' }, + async ({ page }) => { + const diaryPage = new DiaryPage(page); + + await diaryPage.goto(); + + await expect(diaryPage.heading).toBeVisible(); + await expect(diaryPage.heading).toHaveText('Construction Diary'); + }, + ); + + test('Diary page URL is /diary after navigation', async ({ page }) => { + await page.goto(DIARY_ROUTE); + await page.waitForURL('**/diary'); + expect(page.url()).toContain('/diary'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 2: Sidebar navigation to /diary +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Sidebar navigation (Scenario 2)', { tag: '@responsive' }, () => { + test('Navigating to /diary from sidebar lands on Construction Diary page', async ({ page }) => { + const diaryPage = new DiaryPage(page); + + // Start from the home page and navigate via the sidebar "Diary" link + await page.goto('/project/overview'); + await page.waitForLoadState('networkidle'); + + // The sidebar link to /diary — look for an anchor with href containing "/diary" + const diaryNavLink = page.getByRole('link', { name: /diary/i }).first(); + await diaryNavLink.waitFor({ state: 'visible' }); + await diaryNavLink.click(); + + await page.waitForURL('**/diary'); + await expect(diaryPage.heading).toBeVisible(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 3: Empty state when no entries exist (mock API) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Empty state (Scenario 3)', () => { + test('Empty state is shown when the diary has no entries', async ({ page }) => { + const diaryPage = new DiaryPage(page); + + await page.route(`${API.diaryEntries}*`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(makePaginatedResponse([])), + }); + } else { + await route.continue(); + } + }); + + try { + await diaryPage.goto(); + + // Empty state renders when entries.length === 0 and isLoading is false + await expect(diaryPage.emptyState).toBeVisible(); + const text = await diaryPage.emptyState.textContent(); + expect(text?.toLowerCase()).toContain('no diary entries'); + + // CTA link to create first entry + const ctaLink = diaryPage.emptyState.getByRole('link', { + name: /create your first entry/i, + }); + await expect(ctaLink).toBeVisible(); + } finally { + await page.unroute(`${API.diaryEntries}*`); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 4: Entry created via API appears in the timeline +// ───────────────────────────────────────────────────────────────────────────── +test.describe( + 'Entry appears in timeline after API creation (Scenario 4)', + { tag: '@responsive' }, + () => { + test('Diary entry created via API is visible on the list page', async ({ + page, + testPrefix, + }) => { + const diaryPage = new DiaryPage(page); + let createdId: string | null = null; + const title = `${testPrefix} API Created Diary Entry`; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: 'E2E test entry body', + title, + }); + + await diaryPage.goto(); + await diaryPage.waitForLoaded(); + + // Search for this specific title to isolate it from other test data + await diaryPage.search(title); + + // The entry card should appear + await expect(diaryPage.entryCard(createdId)).toBeVisible(); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); + + test('Subtitle shows entry count > 0 after creating an entry', async ({ + page, + testPrefix, + }) => { + const diaryPage = new DiaryPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: `${testPrefix} subtitle count test`, + }); + + await diaryPage.goto(); + await diaryPage.waitForLoaded(); + + const count = await diaryPage.getEntryCount(); + expect(count).toBeGreaterThan(0); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); + }, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 5: Date grouping — entries on different dates render separate headers +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Date grouping (Scenario 5)', () => { + test('Entries on different dates are grouped under separate date headers (mock)', async ({ + page, + }) => { + const diaryPage = new DiaryPage(page); + + const entries = [ + makeMockEntry({ id: 'entry-a', entryDate: '2026-03-14', title: 'Entry A' }), + makeMockEntry({ id: 'entry-b', entryDate: '2026-03-12', title: 'Entry B' }), + ]; + + await page.route(`${API.diaryEntries}*`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(makePaginatedResponse(entries, { totalItems: 2 })), + }); + } else { + await route.continue(); + } + }); + + try { + await diaryPage.goto(); + await diaryPage.waitForLoaded(); + + // Each date should have its own date group section + const group14 = page.getByTestId('date-group-2026-03-14'); + const group12 = page.getByTestId('date-group-2026-03-12'); + + await expect(group14).toBeVisible(); + await expect(group12).toBeVisible(); + + // The two groups are separate — check that we have at least 2 date groups + const groups = diaryPage.dateGroups(); + const groupCount = await groups.count(); + expect(groupCount).toBeGreaterThanOrEqual(2); + } finally { + await page.unroute(`${API.diaryEntries}*`); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 6: Search filter finds a specific entry +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Search filter (Scenario 6)', { tag: '@responsive' }, () => { + test('Search input filters entries to show only matching results', async ({ + page, + testPrefix, + }) => { + const diaryPage = new DiaryPage(page); + const created: string[] = []; + const alphaTitle = `${testPrefix} Alpha Diary Entry`; + const betaTitle = `${testPrefix} Beta Diary Entry`; + + try { + created.push( + await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: 'Alpha entry body', + title: alphaTitle, + }), + ); + created.push( + await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: 'Beta entry body', + title: betaTitle, + }), + ); + + await diaryPage.goto(); + await diaryPage.waitForLoaded(); + + // Search for the alpha entry specifically + await diaryPage.search(`${testPrefix} Alpha`); + + // Alpha entry card should be present + await expect(diaryPage.entryCard(created[0])).toBeVisible(); + + // Beta entry card should not be visible + await expect(diaryPage.entryCard(created[1])).not.toBeVisible(); + } finally { + for (const id of created) { + await deleteDiaryEntryViaApi(page, id); + } + } + }); + + test('Clearing search restores all matching entries', async ({ page, testPrefix }) => { + const diaryPage = new DiaryPage(page); + const created: string[] = []; + + try { + created.push( + await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: `${testPrefix} Clear Alpha`, + title: `${testPrefix} Clear Alpha`, + }), + ); + created.push( + await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: `${testPrefix} Clear Beta`, + title: `${testPrefix} Clear Beta`, + }), + ); + + await diaryPage.goto(); + await diaryPage.waitForLoaded(); + + // Narrow to just alpha + await diaryPage.search(`${testPrefix} Clear Alpha`); + await expect(diaryPage.entryCard(created[0])).toBeVisible(); + await expect(diaryPage.entryCard(created[1])).not.toBeVisible(); + + // Clear the search and wait for the list to reload + await diaryPage.clearSearch(); + // Small pause to let the 300ms debounce from clear() settle before asserting + await page.waitForTimeout(400); + await diaryPage.search(testPrefix); + + // Both entries should be visible again + await expect(diaryPage.entryCard(created[0])).toBeVisible(); + await expect(diaryPage.entryCard(created[1])).toBeVisible(); + } finally { + for (const id of created) { + await deleteDiaryEntryViaApi(page, id); + } + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 7: "Next" pagination button fetches page 2 (mock API) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Pagination (Scenario 7)', () => { + test('Pagination controls are visible when totalPages > 1', async ({ page }) => { + const diaryPage = new DiaryPage(page); + + // Return a multi-page response so the pagination bar renders + const entries = Array.from({ length: 25 }, (_, i) => + makeMockEntry({ + id: `pag-entry-${i}`, + title: `Paginated Entry ${String(i + 1).padStart(2, '0')}`, + }), + ); + + await page.route(`${API.diaryEntries}*`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify( + makePaginatedResponse(entries, { totalItems: 50, totalPages: 2 }), + ), + }); + } else { + await route.continue(); + } + }); + + try { + await diaryPage.goto(); + await diaryPage.waitForLoaded(); + + await expect(diaryPage.prevPageButton).toBeVisible(); + await expect(diaryPage.nextPageButton).toBeVisible(); + + // Previous button disabled on page 1 + await expect(diaryPage.prevPageButton).toBeDisabled(); + + // Next button enabled on page 1 + await expect(diaryPage.nextPageButton).toBeEnabled(); + } finally { + await page.unroute(`${API.diaryEntries}*`); + } + }); + + test('Pagination is not shown when all entries fit on one page', async ({ page }) => { + const diaryPage = new DiaryPage(page); + + await page.route(`${API.diaryEntries}*`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify( + makePaginatedResponse([makeMockEntry()], { totalItems: 1, totalPages: 1 }), + ), + }); + } else { + await route.continue(); + } + }); + + try { + await diaryPage.goto(); + await diaryPage.waitForLoaded(); + + // Pagination buttons are not rendered when totalPages === 1 + await expect(diaryPage.prevPageButton).not.toBeVisible(); + await expect(diaryPage.nextPageButton).not.toBeVisible(); + } finally { + await page.unroute(`${API.diaryEntries}*`); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 8: Entry card click navigates to the detail page +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Entry card navigation (Scenario 8)', () => { + test('Clicking an entry card navigates to the diary entry detail page', async ({ + page, + testPrefix, + }) => { + const diaryPage = new DiaryPage(page); + let createdId: string | null = null; + + try { + createdId = await createDiaryEntryViaApi(page, { + entryType: 'general_note', + entryDate: '2026-03-14', + body: `${testPrefix} card navigation test`, + title: `${testPrefix} Card Nav Test`, + }); + + await diaryPage.goto(); + await diaryPage.waitForLoaded(); + + // Search to locate the card reliably + await diaryPage.search(`${testPrefix} Card Nav Test`); + await expect(diaryPage.entryCard(createdId)).toBeVisible(); + + // Click the card — it is rendered as a so clicking navigates + await diaryPage.entryCard(createdId).click(); + + await page.waitForURL(`**/diary/${createdId}`); + expect(page.url()).toContain(`/diary/${createdId}`); + } finally { + if (createdId) await deleteDiaryEntryViaApi(page, createdId); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 9: Type switcher filters to manual-only entries (mock API) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Type switcher (Scenario 9)', () => { + test('Switching to "Manual" sends correct type filters to the API', async ({ page }) => { + const diaryPage = new DiaryPage(page); + + // Capture API requests to assert the query params + const requests: URL[] = []; + + await page.route(`${API.diaryEntries}*`, async (route) => { + requests.push(new URL(route.request().url())); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(makePaginatedResponse([])), + }); + }); + + try { + await diaryPage.goto(); + await diaryPage.waitForLoaded(); + + // Clear captured requests from the initial load + requests.length = 0; + + // Click the "Manual" switcher button and wait for the API call + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes('/api/diary-entries') && resp.status() === 200, + { timeout: 10_000 }, + ); + await diaryPage.typeSwitcherManual.click(); + await responsePromise; + + // The request should include a 'type' parameter with manual types only + const lastRequest = requests[requests.length - 1]; + expect(lastRequest).toBeDefined(); + const typeParam = lastRequest?.searchParams.get('type'); + // Type param should contain manual types and NOT automatic types + if (typeParam) { + expect(typeParam).toMatch(/daily_log|site_visit|delivery|issue|general_note/); + expect(typeParam).not.toContain('work_item_status'); + expect(typeParam).not.toContain('invoice_status'); + } + } finally { + await page.unroute(`${API.diaryEntries}*`); + } + }); + + test('Type switcher buttons are visible and accessible', async ({ page }) => { + const diaryPage = new DiaryPage(page); + + await diaryPage.goto(); + + await expect(diaryPage.typeSwitcherAll).toBeVisible(); + await expect(diaryPage.typeSwitcherManual).toBeVisible(); + await expect(diaryPage.typeSwitcherAutomatic).toBeVisible(); + + // "All" is the default active mode + await expect(diaryPage.typeSwitcherAll).toHaveAttribute('aria-checked', 'true'); + await expect(diaryPage.typeSwitcherManual).toHaveAttribute('aria-checked', 'false'); + await expect(diaryPage.typeSwitcherAutomatic).toHaveAttribute('aria-checked', 'false'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 10: Responsive — no horizontal scroll +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Responsive layout (Scenario 10)', { tag: '@responsive' }, () => { + test('Diary list page renders without horizontal scroll on current viewport', async ({ + page, + }) => { + const diaryPage = new DiaryPage(page); + + await diaryPage.goto(); + + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + + expect(hasHorizontalScroll).toBe(false); + }); + + test('Filter bar is visible on all viewports', async ({ page }) => { + const diaryPage = new DiaryPage(page); + + await diaryPage.goto(); + + await expect(diaryPage.filterBar).toBeVisible(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 11: Dark mode rendering +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Dark mode rendering (Scenario 11)', { tag: '@responsive' }, () => { + test('Diary list page renders correctly in dark mode without layout overflow', async ({ + page, + }) => { + const diaryPage = new DiaryPage(page); + + await page.goto(DIARY_ROUTE); + await page.evaluate(() => { + document.documentElement.setAttribute('data-theme', 'dark'); + }); + + await diaryPage.heading.waitFor({ state: 'visible' }); + + await expect(diaryPage.heading).toBeVisible(); + + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + expect(hasHorizontalScroll).toBe(false); + }); +}); From 840309ef5e99c24d93807aa300063ec72e719d43 Mon Sep 17 00:00:00 2001 From: "cornerstone-bot[bot]" Date: Sat, 14 Mar 2026 19:53:30 +0000 Subject: [PATCH 05/71] style: auto-fix lint and format [skip ci] --- client/src/App.tsx | 4 ++- .../DiaryEntryCard/DiaryEntryCard.test.tsx | 6 +++- .../diary/DiaryEntryCard/DiaryEntryCard.tsx | 10 +++++-- .../DiaryEntryTypeBadge.test.tsx | 5 +++- .../DiaryEntryTypeSwitcher.test.tsx | 24 ++++------------ .../DiaryFilterBar/DiaryFilterBar.test.tsx | 14 ++++++++-- .../DiaryMetadataSummary.tsx | 18 +++++++++--- client/src/lib/diaryApi.test.ts | 10 ++----- client/src/lib/formatters.ts | 22 +++++++++------ .../DiaryEntryDetailPage.test.tsx | 4 +-- client/src/pages/DiaryPage/DiaryPage.test.tsx | 12 ++++---- client/src/pages/DiaryPage/DiaryPage.tsx | 28 +++++++++++-------- e2e/pages/DiaryEntryDetailPage.ts | 7 +++-- e2e/tests/diary/diary-detail.spec.ts | 24 ++++------------ e2e/tests/diary/diary-list.spec.ts | 16 +++++------ 15 files changed, 107 insertions(+), 97 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 9343a46dc..09b77879f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -66,7 +66,9 @@ const UserManagementPage = lazy(() => import('./pages/UserManagementPage/UserMan const InvoicesPage = lazy(() => import('./pages/InvoicesPage/InvoicesPage')); const InvoiceDetailPage = lazy(() => import('./pages/InvoiceDetailPage/InvoiceDetailPage')); const DiaryPage = lazy(() => import('./pages/DiaryPage/DiaryPage')); -const DiaryEntryDetailPage = lazy(() => import('./pages/DiaryEntryDetailPage/DiaryEntryDetailPage')); +const DiaryEntryDetailPage = lazy( + () => import('./pages/DiaryEntryDetailPage/DiaryEntryDetailPage'), +); const NotFoundPage = lazy(() => import('./pages/NotFoundPage/NotFoundPage')); export function App() { diff --git a/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.test.tsx b/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.test.tsx index baacb7a68..bd39e51df 100644 --- a/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.test.tsx +++ b/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.test.tsx @@ -31,7 +31,11 @@ const automaticEntry: DiaryEntrySummary = { entryDate: '2026-03-14', title: null, body: 'Work item "Kitchen Installation" changed status to in_progress.', - metadata: { changeSummary: 'Status changed to in_progress', previousValue: 'not_started', newValue: 'in_progress' }, + metadata: { + changeSummary: 'Status changed to in_progress', + previousValue: 'not_started', + newValue: 'in_progress', + }, isAutomatic: true, sourceEntityType: 'work_item', sourceEntityId: 'wi-kitchen-1', diff --git a/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.tsx b/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.tsx index 0be48a138..1e3f74dfa 100644 --- a/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.tsx +++ b/client/src/components/diary/DiaryEntryCard/DiaryEntryCard.tsx @@ -55,14 +55,20 @@ export function DiaryEntryCard({ entry }: DiaryEntryCardProps) { .join(' '); return ( - +
{entry.title &&
{entry.title}
}
{formatTime(entry.createdAt)} - {entry.createdBy && by {entry.createdBy.displayName}} + {entry.createdBy && ( + by {entry.createdBy.displayName} + )}
diff --git a/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.test.tsx b/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.test.tsx index 4a8bd7d2f..57a2ca321 100644 --- a/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.test.tsx +++ b/client/src/components/diary/DiaryEntryTypeBadge/DiaryEntryTypeBadge.test.tsx @@ -94,7 +94,10 @@ describe('DiaryEntryTypeBadge', () => { it('has title "Site Visit" for site_visit', () => { render(); - expect(screen.getByTestId('diary-type-badge-site_visit')).toHaveAttribute('title', 'Site Visit'); + expect(screen.getByTestId('diary-type-badge-site_visit')).toHaveAttribute( + 'title', + 'Site Visit', + ); }); // ─── size prop ───────────────────────────────────────────────────────────── diff --git a/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.test.tsx b/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.test.tsx index f1c7acfb6..f956eb4d5 100644 --- a/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.test.tsx +++ b/client/src/components/diary/DiaryEntryTypeSwitcher/DiaryEntryTypeSwitcher.test.tsx @@ -98,9 +98,7 @@ describe('DiaryEntryTypeSwitcher', () => { it('calls onChange with "manual" when ArrowRight is pressed while "all" is active', () => { const onChange = jest.fn(); - const { container } = render( - , - ); + const { container } = render(); const radiogroup = container.querySelector('[role="radiogroup"]')!; fireEvent.keyDown(radiogroup, { key: 'ArrowRight' }); @@ -110,9 +108,7 @@ describe('DiaryEntryTypeSwitcher', () => { it('calls onChange with "all" when ArrowLeft is pressed while "manual" is active', () => { const onChange = jest.fn(); - const { container } = render( - , - ); + const { container } = render(); const radiogroup = container.querySelector('[role="radiogroup"]')!; fireEvent.keyDown(radiogroup, { key: 'ArrowLeft' }); @@ -122,9 +118,7 @@ describe('DiaryEntryTypeSwitcher', () => { it('calls onChange with "automatic" when ArrowRight is pressed while "manual" is active', () => { const onChange = jest.fn(); - const { container } = render( - , - ); + const { container } = render(); const radiogroup = container.querySelector('[role="radiogroup"]')!; fireEvent.keyDown(radiogroup, { key: 'ArrowRight' }); @@ -134,9 +128,7 @@ describe('DiaryEntryTypeSwitcher', () => { it('does not call onChange when ArrowRight is pressed on the last option ("automatic")', () => { const onChange = jest.fn(); - const { container } = render( - , - ); + const { container } = render(); const radiogroup = container.querySelector('[role="radiogroup"]')!; fireEvent.keyDown(radiogroup, { key: 'ArrowRight' }); @@ -146,9 +138,7 @@ describe('DiaryEntryTypeSwitcher', () => { it('does not call onChange when ArrowLeft is pressed on the first option ("all")', () => { const onChange = jest.fn(); - const { container } = render( - , - ); + const { container } = render(); const radiogroup = container.querySelector('[role="radiogroup"]')!; fireEvent.keyDown(radiogroup, { key: 'ArrowLeft' }); @@ -158,9 +148,7 @@ describe('DiaryEntryTypeSwitcher', () => { it('does not call onChange for irrelevant key presses', () => { const onChange = jest.fn(); - const { container } = render( - , - ); + const { container } = render(); const radiogroup = container.querySelector('[role="radiogroup"]')!; fireEvent.keyDown(radiogroup, { key: 'Enter' }); diff --git a/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.test.tsx b/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.test.tsx index 59849efd9..62e1c6265 100644 --- a/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.test.tsx +++ b/client/src/components/diary/DiaryFilterBar/DiaryFilterBar.test.tsx @@ -57,9 +57,17 @@ describe('DiaryFilterBar', () => { it('renders type filter chips for all entry types', () => { renderFilterBar(); const expectedTypes: DiaryEntryType[] = [ - 'daily_log', 'site_visit', 'delivery', 'issue', 'general_note', - 'work_item_status', 'invoice_status', 'milestone_delay', 'budget_breach', - 'auto_reschedule', 'subsidy_status', + 'daily_log', + 'site_visit', + 'delivery', + 'issue', + 'general_note', + 'work_item_status', + 'invoice_status', + 'milestone_delay', + 'budget_breach', + 'auto_reschedule', + 'subsidy_status', ]; for (const type of expectedTypes) { expect(screen.getByTestId(`type-filter-${type}`)).toBeInTheDocument(); diff --git a/client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.tsx b/client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.tsx index 271c093d1..28ee0e9ff 100644 --- a/client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.tsx +++ b/client/src/components/diary/DiaryMetadataSummary/DiaryMetadataSummary.tsx @@ -85,11 +85,21 @@ export function DiaryMetadataSummary({ entryType, metadata }: DiaryMetadataSumma ); } - if (entryType.startsWith('work_item_') || entryType.startsWith('invoice_') || - entryType.startsWith('milestone_') || entryType.startsWith('budget_') || - entryType.startsWith('auto_') || entryType.startsWith('subsidy_')) { + if ( + entryType.startsWith('work_item_') || + entryType.startsWith('invoice_') || + entryType.startsWith('milestone_') || + entryType.startsWith('budget_') || + entryType.startsWith('auto_') || + entryType.startsWith('subsidy_') + ) { // Automatic entry type - if (metadata && typeof metadata === 'object' && 'changeSummary' in metadata && metadata.changeSummary) { + if ( + metadata && + typeof metadata === 'object' && + 'changeSummary' in metadata && + metadata.changeSummary + ) { return ( {String((metadata as Record).changeSummary)} diff --git a/client/src/lib/diaryApi.test.ts b/client/src/lib/diaryApi.test.ts index dba38aaf2..0907d5855 100644 --- a/client/src/lib/diaryApi.test.ts +++ b/client/src/lib/diaryApi.test.ts @@ -76,10 +76,7 @@ describe('diaryApi', () => { await listDiaryEntries({ pageSize: 50 }); - expect(mockFetch).toHaveBeenCalledWith( - '/api/diary-entries?pageSize=50', - expect.any(Object), - ); + expect(mockFetch).toHaveBeenCalledWith('/api/diary-entries?pageSize=50', expect.any(Object)); }); it('includes type param when provided', async () => { @@ -146,10 +143,7 @@ describe('diaryApi', () => { await listDiaryEntries({ q: 'foundation' }); - expect(mockFetch).toHaveBeenCalledWith( - '/api/diary-entries?q=foundation', - expect.any(Object), - ); + expect(mockFetch).toHaveBeenCalledWith('/api/diary-entries?q=foundation', expect.any(Object)); }); it('includes multiple params when provided', async () => { diff --git a/client/src/lib/formatters.ts b/client/src/lib/formatters.ts index 338fe281e..5cc265799 100644 --- a/client/src/lib/formatters.ts +++ b/client/src/lib/formatters.ts @@ -91,15 +91,19 @@ export function formatDateTime(timestamp: string | null | undefined, fallback = if (!timestamp) return fallback; try { const date = new Date(timestamp); - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }) + ' at ' + date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); + return ( + date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + + ' at ' + + date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) + ); } catch { return fallback; } diff --git a/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.test.tsx b/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.test.tsx index 8ff839b0d..3c76ddc65 100644 --- a/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.test.tsx +++ b/client/src/pages/DiaryEntryDetailPage/DiaryEntryDetailPage.test.tsx @@ -91,9 +91,7 @@ describe('DiaryEntryDetailPage', () => { mockGetDiaryEntry.mockResolvedValueOnce(baseDetail); renderDetailPage(); await waitFor(() => { - expect( - screen.getByText('Poured concrete for the main foundation.'), - ).toBeInTheDocument(); + expect(screen.getByText('Poured concrete for the main foundation.')).toBeInTheDocument(); }); }); diff --git a/client/src/pages/DiaryPage/DiaryPage.test.tsx b/client/src/pages/DiaryPage/DiaryPage.test.tsx index ffef92473..1126c46e7 100644 --- a/client/src/pages/DiaryPage/DiaryPage.test.tsx +++ b/client/src/pages/DiaryPage/DiaryPage.test.tsx @@ -83,11 +83,15 @@ describe('DiaryPage', () => { it('renders the "Construction Diary" h1 heading', async () => { mockListDiaryEntries.mockResolvedValueOnce(emptyResponse); renderPage(); - expect(screen.getByRole('heading', { name: 'Construction Diary', level: 1 })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Construction Diary', level: 1 }), + ).toBeInTheDocument(); }); it('shows the total entry count in the subtitle', async () => { - mockListDiaryEntries.mockResolvedValueOnce(makeListResponse([makeSummary('1'), makeSummary('2')])); + mockListDiaryEntries.mockResolvedValueOnce( + makeListResponse([makeSummary('1'), makeSummary('2')]), + ); renderPage(); await waitFor(() => { expect(screen.getByText(/2 entries/i)).toBeInTheDocument(); @@ -202,9 +206,7 @@ describe('DiaryPage', () => { mockListDiaryEntries.mockRejectedValueOnce(new Error('Network error')); renderPage(); await waitFor(() => { - expect( - screen.getByText(/failed to load diary entries/i), - ).toBeInTheDocument(); + expect(screen.getByText(/failed to load diary entries/i)).toBeInTheDocument(); }); }); diff --git a/client/src/pages/DiaryPage/DiaryPage.tsx b/client/src/pages/DiaryPage/DiaryPage.tsx index 67fa2ea8c..ff202572a 100644 --- a/client/src/pages/DiaryPage/DiaryPage.tsx +++ b/client/src/pages/DiaryPage/DiaryPage.tsx @@ -83,14 +83,22 @@ export default function DiaryPage() { // Determine which types to query based on filter mode let queriableTypes: DiaryEntryType[] = activeTypes; if (filterMode === 'manual') { - queriableTypes = activeTypes.length > 0 - ? activeTypes.filter((t) => MANUAL_TYPES.has(t as any)) - : Array.from(MANUAL_TYPES) as DiaryEntryType[]; + queriableTypes = + activeTypes.length > 0 + ? activeTypes.filter((t) => MANUAL_TYPES.has(t as any)) + : (Array.from(MANUAL_TYPES) as DiaryEntryType[]); } else if (filterMode === 'automatic') { - queriableTypes = activeTypes.length > 0 - ? activeTypes.filter((t) => !MANUAL_TYPES.has(t as any)) - : (['work_item_status', 'invoice_status', 'milestone_delay', 'budget_breach', - 'auto_reschedule', 'subsidy_status'] as const as unknown as DiaryEntryType[]); + queriableTypes = + activeTypes.length > 0 + ? activeTypes.filter((t) => !MANUAL_TYPES.has(t as any)) + : ([ + 'work_item_status', + 'invoice_status', + 'milestone_delay', + 'budget_breach', + 'auto_reschedule', + 'subsidy_status', + ] as const as unknown as DiaryEntryType[]); } const response = await listDiaryEntries({ @@ -237,11 +245,7 @@ export default function DiaryPage() { {!isLoading && entries.length > 0 && (
{sortedDates.map((date) => ( - + ))}
)} diff --git a/e2e/pages/DiaryEntryDetailPage.ts b/e2e/pages/DiaryEntryDetailPage.ts index 72de8d20b..8c9d86490 100644 --- a/e2e/pages/DiaryEntryDetailPage.ts +++ b/e2e/pages/DiaryEntryDetailPage.ts @@ -76,9 +76,10 @@ export class DiaryEntryDetailPage { this.backToDiaryLink = page.getByRole('link', { name: 'Back to Diary' }); // Entry title h1 (conditional — only rendered when entry.title is set) - this.entryTitle = page.locator('[class*="title"]').filter({ has: page.locator('h1') }).or( - page.getByRole('heading', { level: 1 }), - ); + this.entryTitle = page + .locator('[class*="title"]') + .filter({ has: page.locator('h1') }) + .or(page.getByRole('heading', { level: 1 })); this.entryBody = page.locator('[class*="body"]').first(); this.entryDate = page.locator('[class*="date"]').first(); diff --git a/e2e/tests/diary/diary-detail.spec.ts b/e2e/tests/diary/diary-detail.spec.ts index 9640a5c5c..96e3cfaef 100644 --- a/e2e/tests/diary/diary-detail.spec.ts +++ b/e2e/tests/diary/diary-detail.spec.ts @@ -55,10 +55,7 @@ test.describe('Page load (Scenario 1)', { tag: '@responsive' }, () => { }, ); - test('Entry title h1 is rendered when the entry has a title', async ({ - page, - testPrefix, - }) => { + test('Entry title h1 is rendered when the entry has a title', async ({ page, testPrefix }) => { const detailPage = new DiaryEntryDetailPage(page); let createdId: string | null = null; const title = `${testPrefix} Detail Title Test`; @@ -123,10 +120,7 @@ test.describe('Back button navigation (Scenario 2)', { tag: '@responsive' }, () // Scenario 3: "Back to Diary" link navigates to /diary // ───────────────────────────────────────────────────────────────────────────── test.describe('"Back to Diary" link (Scenario 3)', { tag: '@responsive' }, () => { - test('"Back to Diary" link at the bottom navigates to /diary', async ({ - page, - testPrefix, - }) => { + test('"Back to Diary" link at the bottom navigates to /diary', async ({ page, testPrefix }) => { const detailPage = new DiaryEntryDetailPage(page); let createdId: string | null = null; @@ -193,10 +187,7 @@ test.describe('daily_log metadata (Scenario 4)', () => { // Scenario 5: site_visit outcome badge renders // ───────────────────────────────────────────────────────────────────────────── test.describe('site_visit outcome badge (Scenario 5)', () => { - test('site_visit entry shows outcome badge for "pass" result', async ({ - page, - testPrefix, - }) => { + test('site_visit entry shows outcome badge for "pass" result', async ({ page, testPrefix }) => { const detailPage = new DiaryEntryDetailPage(page); let createdId: string | null = null; @@ -252,10 +243,7 @@ test.describe('site_visit outcome badge (Scenario 5)', () => { // Scenario 6: issue severity badge renders // ───────────────────────────────────────────────────────────────────────────── test.describe('issue severity badge (Scenario 6)', () => { - test('issue entry shows severity badge for "critical" severity', async ({ - page, - testPrefix, - }) => { + test('issue entry shows severity badge for "critical" severity', async ({ page, testPrefix }) => { const detailPage = new DiaryEntryDetailPage(page); let createdId: string | null = null; @@ -331,9 +319,7 @@ test.describe('404 error state (Scenario 7)', { tag: '@responsive' }, () => { // Scenario 8: "Automatic" badge shown for system-generated entries (mock API) // ───────────────────────────────────────────────────────────────────────────── test.describe('Automatic entry badge (Scenario 8)', () => { - test('Automatic system entry shows an "Automatic" badge in the detail view', async ({ - page, - }) => { + test('Automatic system entry shows an "Automatic" badge in the detail view', async ({ page }) => { const detailPage = new DiaryEntryDetailPage(page); const mockId = 'mock-auto-entry-001'; diff --git a/e2e/tests/diary/diary-list.spec.ts b/e2e/tests/diary/diary-list.spec.ts index f9d5ba818..e6702795a 100644 --- a/e2e/tests/diary/diary-list.spec.ts +++ b/e2e/tests/diary/diary-list.spec.ts @@ -47,7 +47,12 @@ function makeMockEntry(overrides: Partial> = {}): Record function makePaginatedResponse( entries: Record[], - overrides: Partial<{ page: number; pageSize: number; totalItems: number; totalPages: number }> = {}, + overrides: Partial<{ + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + }> = {}, ): Record { return { data: entries, @@ -180,10 +185,7 @@ test.describe( } }); - test('Subtitle shows entry count > 0 after creating an entry', async ({ - page, - testPrefix, - }) => { + test('Subtitle shows entry count > 0 after creating an entry', async ({ page, testPrefix }) => { const diaryPage = new DiaryPage(page); let createdId: string | null = null; @@ -369,9 +371,7 @@ test.describe('Pagination (Scenario 7)', () => { await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify( - makePaginatedResponse(entries, { totalItems: 50, totalPages: 2 }), - ), + body: JSON.stringify(makePaginatedResponse(entries, { totalItems: 50, totalPages: 2 })), }); } else { await route.continue(); From e72006edf935c1bbc3809f6596a0d60835d8f111 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Sat, 14 Mar 2026 21:38:15 +0100 Subject: [PATCH 06/71] feat(diary): add automatic system event logging to diary (#814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(diary): add automatic system event logging to diary Implement fire-and-forget diary auto-events triggered by state changes: - Work item status changes → work_item_status entries - Invoice status changes → invoice_status entries - Milestone delays → milestone_delay entries - Budget overspend → budget_breach entries - Auto-reschedule completion → auto_reschedule entries - Subsidy status changes → subsidy_status entries Also: - Add DIARY_AUTO_EVENTS env var (default true) for global toggle - Allow deletion of automatic entries (edit still blocked, 403) - New diaryAutoEventService with tryCreateDiaryEntry wrapper - Hooks in workItemService, invoiceService, schedulingEngine, subsidyProgramService - Unit tests for all event functions and fire-and-forget behavior Fixes #808 Co-Authored-By: Claude backend-developer (Haiku) Co-Authored-By: Claude qa-integration-tester (Sonnet) Co-Authored-By: Claude dev-team-lead (Sonnet) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(diary): use milestone.title instead of milestone.name Milestones have a 'title' property, not 'name'. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(diary): fix config tests, enrich event body text, remove dead import - Add diaryAutoEvents to all config.test.ts toEqual assertions - Enrich body text: include work item title, status transitions - Remove unused onAutoRescheduleCompleted import from schedulingEngine - Update diaryAutoEventService test assertions to match enriched text Co-Authored-By: Claude Opus 4.6 (1M context) * fix(diary): fix ESM mock issue in fire-and-forget test Use closed DB to trigger errors instead of jest.spyOn on ESM module (which throws "Cannot assign to read only property"). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude product-architect (Opus 4.6) --- server/src/errors/AppError.ts | 4 +- server/src/plugins/config.test.ts | 4 + server/src/plugins/config.ts | 10 + server/src/routes/diary.test.ts | 21 +- server/src/routes/invoices.ts | 1 + server/src/routes/subsidyPrograms.ts | 1 + server/src/routes/workItems.ts | 2 +- .../services/diaryAutoEventService.test.ts | 354 ++++++++++++++++++ server/src/services/diaryAutoEventService.ts | 219 +++++++++++ server/src/services/diaryService.test.ts | 17 +- server/src/services/diaryService.ts | 7 - server/src/services/invoiceService.ts | 27 ++ server/src/services/schedulingEngine.ts | 34 +- server/src/services/subsidyProgramService.ts | 19 + server/src/services/workItemService.ts | 34 +- 15 files changed, 729 insertions(+), 25 deletions(-) create mode 100644 server/src/services/diaryAutoEventService.test.ts create mode 100644 server/src/services/diaryAutoEventService.ts diff --git a/server/src/errors/AppError.ts b/server/src/errors/AppError.ts index 649e8afe5..ec8d6ef9a 100644 --- a/server/src/errors/AppError.ts +++ b/server/src/errors/AppError.ts @@ -191,8 +191,8 @@ export class InvalidMetadataError extends AppError { } export class ImmutableEntryError extends AppError { - constructor(message = 'Automatic diary entries cannot be modified or deleted') { - super('IMMUTABLE_ENTRY', 400, message); + constructor(message = 'Automatic diary entries cannot be modified') { + super('IMMUTABLE_ENTRY', 403, message); this.name = 'ImmutableEntryError'; } } diff --git a/server/src/plugins/config.test.ts b/server/src/plugins/config.test.ts index 709e4d889..79df227dc 100644 --- a/server/src/plugins/config.test.ts +++ b/server/src/plugins/config.test.ts @@ -32,6 +32,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => { paperlessEnabled: false, photoStoragePath: '/app/data/photos', photoMaxFileSizeMb: 20, + diaryAutoEvents: true, }); }); @@ -65,6 +66,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => { paperlessEnabled: false, photoStoragePath: '/app/data/photos', photoMaxFileSizeMb: 20, + diaryAutoEvents: true, }); }); }); @@ -100,6 +102,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => { paperlessEnabled: false, photoStoragePath: '/custom/path/photos', photoMaxFileSizeMb: 20, + diaryAutoEvents: true, }); }); @@ -130,6 +133,7 @@ describe('Configuration Module - loadConfig() Pure Function', () => { paperlessEnabled: false, photoStoragePath: '/app/data/photos', photoMaxFileSizeMb: 20, + diaryAutoEvents: true, }); }); }); diff --git a/server/src/plugins/config.ts b/server/src/plugins/config.ts index 493698ee2..aa60dc191 100644 --- a/server/src/plugins/config.ts +++ b/server/src/plugins/config.ts @@ -23,6 +23,7 @@ export interface AppConfig { paperlessEnabled: boolean; photoStoragePath: string; photoMaxFileSizeMb: number; + diaryAutoEvents: boolean; } // Type augmentation: makes fastify.config available across all routes/plugins @@ -168,6 +169,13 @@ export function loadConfig(env: Record): AppConfig { const photoStoragePath = getValue('PHOTO_STORAGE_PATH') ?? path.join(path.dirname(databaseUrl), 'photos'); + // Parse and validate DIARY_AUTO_EVENTS + const diaryAutoEventsStr = (getValue('DIARY_AUTO_EVENTS') ?? 'true').toLowerCase(); + if (diaryAutoEventsStr !== 'true' && diaryAutoEventsStr !== 'false') { + errors.push(`DIARY_AUTO_EVENTS must be 'true' or 'false', got: ${getValue('DIARY_AUTO_EVENTS')}`); + } + const diaryAutoEvents = diaryAutoEventsStr === 'true'; + // If there are any validation errors, throw a single error listing all of them if (errors.length > 0) { throw new Error(`Configuration validation failed:\n - ${errors.join('\n - ')}`); @@ -194,6 +202,7 @@ export function loadConfig(env: Record): AppConfig { paperlessEnabled, photoStoragePath, photoMaxFileSizeMb, + diaryAutoEvents, }; } @@ -220,6 +229,7 @@ export default fp( paperlessFilterTag: config.paperlessFilterTag, photoStoragePath: config.photoStoragePath, photoMaxFileSizeMb: config.photoMaxFileSizeMb, + diaryAutoEvents: config.diaryAutoEvents, }, 'Configuration loaded', ); diff --git a/server/src/routes/diary.test.ts b/server/src/routes/diary.test.ts index d6da1e5bf..ebb4b258a 100644 --- a/server/src/routes/diary.test.ts +++ b/server/src/routes/diary.test.ts @@ -438,7 +438,7 @@ describe('Diary Routes', () => { expect(error.error.code).toBe('NOT_FOUND'); }); - it('returns 400 IMMUTABLE_ENTRY when updating an automatic entry', async () => { + it('returns 403 IMMUTABLE_ENTRY when updating an automatic entry', async () => { const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); const id = insertDiaryEntry({ isAutomatic: true, @@ -453,7 +453,8 @@ describe('Diary Routes', () => { payload: { body: 'Should not update' }, }); - expect(response.statusCode).toBe(400); + // ImmutableEntryError has statusCode 403 (Story #808) + expect(response.statusCode).toBe(403); const error = response.json(); expect(error.error.code).toBe('IMMUTABLE_ENTRY'); }); @@ -507,7 +508,7 @@ describe('Diary Routes', () => { expect(error.error.code).toBe('NOT_FOUND'); }); - it('returns 400 IMMUTABLE_ENTRY when deleting an automatic entry', async () => { + it('returns 204 when deleting an automatic entry (automatic entries can be deleted)', async () => { const { cookie } = await createUserWithSession('user@test.com', 'Test User', 'password'); const id = insertDiaryEntry({ isAutomatic: true, @@ -521,9 +522,17 @@ describe('Diary Routes', () => { headers: { cookie }, }); - expect(response.statusCode).toBe(400); - const error = response.json(); - expect(error.error.code).toBe('IMMUTABLE_ENTRY'); + // Story #808: automatic entries can now be deleted + expect(response.statusCode).toBe(204); + expect(response.body).toBe(''); + + // Verify entry is gone + const getResponse = await app.inject({ + method: 'GET', + url: `/api/diary-entries/${id}`, + headers: { cookie }, + }); + expect(getResponse.statusCode).toBe(404); }); }); }); diff --git a/server/src/routes/invoices.ts b/server/src/routes/invoices.ts index 2fe4e5375..19c915964 100644 --- a/server/src/routes/invoices.ts +++ b/server/src/routes/invoices.ts @@ -135,6 +135,7 @@ export default async function invoiceRoutes(fastify: FastifyInstance) { request.params.vendorId, request.params.invoiceId, request.body, + fastify.config.diaryAutoEvents, ); return reply.status(200).send({ invoice }); }, diff --git a/server/src/routes/subsidyPrograms.ts b/server/src/routes/subsidyPrograms.ts index 772dbd97b..e6fe5042e 100644 --- a/server/src/routes/subsidyPrograms.ts +++ b/server/src/routes/subsidyPrograms.ts @@ -150,6 +150,7 @@ export default async function subsidyProgramRoutes(fastify: FastifyInstance) { fastify.db, request.params.id, request.body, + fastify.config.diaryAutoEvents, ); return reply.status(200).send({ subsidyProgram }); }, diff --git a/server/src/routes/workItems.ts b/server/src/routes/workItems.ts index 7bb49ffcb..89b474de2 100644 --- a/server/src/routes/workItems.ts +++ b/server/src/routes/workItems.ts @@ -192,7 +192,7 @@ export default async function workItemRoutes(fastify: FastifyInstance) { const { id } = request.params as { id: string }; const data = request.body as UpdateWorkItemRequest; - const workItem = workItemService.updateWorkItem(fastify.db, id, data); + const workItem = workItemService.updateWorkItem(fastify.db, id, data, fastify.config.diaryAutoEvents); return reply.status(200).send(workItem); }); diff --git a/server/src/services/diaryAutoEventService.test.ts b/server/src/services/diaryAutoEventService.test.ts new file mode 100644 index 000000000..98a964350 --- /dev/null +++ b/server/src/services/diaryAutoEventService.test.ts @@ -0,0 +1,354 @@ +/** + * Unit tests for diaryAutoEventService.ts + * + * EPIC-13: Construction Diary — Story #808 + * Tests all 6 event functions and fire-and-forget behavior. + * Uses a real in-memory SQLite DB with migrations. + */ + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { runMigrations } from '../db/migrate.js'; +import * as schema from '../db/schema.js'; +import { diaryEntries } from '../db/schema.js'; +import { + onWorkItemStatusChanged, + onInvoiceStatusChanged, + onMilestoneDelayed, + onBudgetCategoryOverspend, + onAutoRescheduleCompleted, + onSubsidyStatusChanged, +} from './diaryAutoEventService.js'; +// diaryService is imported internally by diaryAutoEventService — not needed here + +// Suppress migration logs +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +describe('diaryAutoEventService', () => { + let db: BetterSQLite3Database; + let sqlite: ReturnType; + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'diary-auto-event-test-')); + const dbPath = join(tempDir, 'test.db'); + sqlite = new Database(dbPath); + runMigrations(sqlite, undefined); + db = drizzle(sqlite, { schema }); + }); + + afterEach(() => { + sqlite.close(); + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + jest.restoreAllMocks(); + }); + + // ─── Helper: query all diary entries directly ───────────────────────────── + + function getAllEntries() { + return db.select().from(diaryEntries).all(); + } + + // ─── onWorkItemStatusChanged ─────────────────────────────────────────────── + + describe('onWorkItemStatusChanged', () => { + it('creates a diary entry when enabled=true', () => { + onWorkItemStatusChanged( + db, + true, + 'wi-test-001', + 'Foundation Work', + 'not_started', + 'in_progress', + ); + + const entries = getAllEntries(); + expect(entries).toHaveLength(1); + + const entry = entries[0]; + expect(entry.entryType).toBe('work_item_status'); + expect(entry.isAutomatic).toBe(true); + expect(entry.sourceEntityType).toBe('work_item'); + expect(entry.sourceEntityId).toBe('wi-test-001'); + expect(entry.createdBy).toBeNull(); + expect(entry.body).toContain('Foundation Work'); + expect(entry.body).toContain('not_started'); + expect(entry.body).toContain('in_progress'); + }); + + it('does not create a diary entry when enabled=false', () => { + onWorkItemStatusChanged( + db, + false, + 'wi-test-002', + 'Roof Installation', + 'in_progress', + 'completed', + ); + + const entries = getAllEntries(); + expect(entries).toHaveLength(0); + }); + + it('stores metadata with previousValue and newValue', () => { + onWorkItemStatusChanged( + db, + true, + 'wi-test-003', + 'Electrical Wiring', + 'not_started', + 'completed', + ); + + const entries = getAllEntries(); + expect(entries).toHaveLength(1); + + const metadata = JSON.parse(entries[0].metadata ?? 'null'); + expect(metadata).not.toBeNull(); + expect(metadata.previousValue).toBe('not_started'); + expect(metadata.newValue).toBe('completed'); + expect(typeof metadata.changeSummary).toBe('string'); + }); + }); + + // ─── onInvoiceStatusChanged ──────────────────────────────────────────────── + + describe('onInvoiceStatusChanged', () => { + it('creates a diary entry with entryType=invoice_status, body contains invoice number', () => { + onInvoiceStatusChanged( + db, + true, + 'inv-001', + 'INV-2026-042', + 'pending', + 'paid', + ); + + const entries = getAllEntries(); + expect(entries).toHaveLength(1); + + const entry = entries[0]; + expect(entry.entryType).toBe('invoice_status'); + expect(entry.isAutomatic).toBe(true); + expect(entry.sourceEntityType).toBe('invoice'); + expect(entry.sourceEntityId).toBe('inv-001'); + expect(entry.body).toContain('INV-2026-042'); + expect(entry.body).toContain('pending'); + expect(entry.body).toContain('paid'); + }); + + it('body uses invoiceNumber param (even if empty string, shows N/A)', () => { + // When invoiceNumber is empty string, the service uses 'N/A' per the template + onInvoiceStatusChanged( + db, + true, + 'inv-002', + '', + 'pending', + 'claimed', + ); + + const entries = getAllEntries(); + expect(entries).toHaveLength(1); + + // Empty string is falsy, so template renders 'N/A' + expect(entries[0].body).toContain('N/A'); + }); + + it('stores metadata with previousValue, newValue, and changeSummary', () => { + onInvoiceStatusChanged( + db, + true, + 'inv-003', + 'INV-2026-100', + 'pending', + 'paid', + ); + + const entries = getAllEntries(); + expect(entries).toHaveLength(1); + const metadata = JSON.parse(entries[0].metadata ?? 'null'); + expect(metadata.previousValue).toBe('pending'); + expect(metadata.newValue).toBe('paid'); + expect(metadata.changeSummary).toContain('pending'); + expect(metadata.changeSummary).toContain('paid'); + }); + + it('does not create entry when enabled=false', () => { + onInvoiceStatusChanged(db, false, 'inv-004', 'INV-999', 'pending', 'paid'); + expect(getAllEntries()).toHaveLength(0); + }); + }); + + // ─── onMilestoneDelayed ──────────────────────────────────────────────────── + + describe('onMilestoneDelayed', () => { + it('creates a diary entry with entryType=milestone_delay, body contains milestone name', () => { + onMilestoneDelayed(db, true, 42, 'Foundation Complete'); + + const entries = getAllEntries(); + expect(entries).toHaveLength(1); + + const entry = entries[0]; + expect(entry.entryType).toBe('milestone_delay'); + expect(entry.isAutomatic).toBe(true); + expect(entry.sourceEntityType).toBe('milestone'); + expect(entry.sourceEntityId).toBe('42'); + expect(entry.body).toContain('Foundation Complete'); + }); + + it('converts milestoneId (number) to string for sourceEntityId', () => { + onMilestoneDelayed(db, true, 999, 'Roof Complete'); + + const entries = getAllEntries(); + expect(entries[0].sourceEntityId).toBe('999'); + }); + + it('does not create entry when enabled=false', () => { + onMilestoneDelayed(db, false, 1, 'Some Milestone'); + expect(getAllEntries()).toHaveLength(0); + }); + }); + + // ─── onBudgetCategoryOverspend ───────────────────────────────────────────── + + describe('onBudgetCategoryOverspend', () => { + it('creates a diary entry with entryType=budget_breach, body contains category name', () => { + onBudgetCategoryOverspend(db, true, 'bc-structural', 'Structural Work'); + + const entries = getAllEntries(); + expect(entries).toHaveLength(1); + + const entry = entries[0]; + expect(entry.entryType).toBe('budget_breach'); + expect(entry.isAutomatic).toBe(true); + expect(entry.sourceEntityType).toBe('budget_source'); + expect(entry.sourceEntityId).toBe('bc-structural'); + expect(entry.body).toContain('Structural Work'); + }); + + it('does not create entry when enabled=false', () => { + onBudgetCategoryOverspend(db, false, 'bc-electrical', 'Electrical'); + expect(getAllEntries()).toHaveLength(0); + }); + }); + + // ─── onAutoRescheduleCompleted ───────────────────────────────────────────── + + describe('onAutoRescheduleCompleted', () => { + it('creates a diary entry with entryType=auto_reschedule when count > 0', () => { + onAutoRescheduleCompleted(db, true, 5); + + const entries = getAllEntries(); + expect(entries).toHaveLength(1); + + const entry = entries[0]; + expect(entry.entryType).toBe('auto_reschedule'); + expect(entry.isAutomatic).toBe(true); + expect(entry.sourceEntityType).toBeNull(); + expect(entry.sourceEntityId).toBeNull(); + expect(entry.body).toContain('5'); + expect(entry.body).toContain('updated'); + }); + + it('stores metadata with itemCount equal to updatedCount', () => { + onAutoRescheduleCompleted(db, true, 3); + + const entries = getAllEntries(); + const metadata = JSON.parse(entries[0].metadata ?? 'null'); + expect(metadata.itemCount).toBe(3); + }); + + it('does not create entry when count = 0', () => { + onAutoRescheduleCompleted(db, true, 0); + expect(getAllEntries()).toHaveLength(0); + }); + + it('does not create entry when enabled=false, even when count > 0', () => { + onAutoRescheduleCompleted(db, false, 10); + expect(getAllEntries()).toHaveLength(0); + }); + }); + + // ─── onSubsidyStatusChanged ──────────────────────────────────────────────── + + describe('onSubsidyStatusChanged', () => { + it('creates a diary entry with entryType=subsidy_status, body contains subsidy name', () => { + onSubsidyStatusChanged( + db, + true, + 'sub-kfw-001', + 'KfW Energy Grant', + 'applied', + 'approved', + ); + + const entries = getAllEntries(); + expect(entries).toHaveLength(1); + + const entry = entries[0]; + expect(entry.entryType).toBe('subsidy_status'); + expect(entry.isAutomatic).toBe(true); + expect(entry.sourceEntityType).toBe('subsidy_program'); + expect(entry.sourceEntityId).toBe('sub-kfw-001'); + expect(entry.body).toContain('KfW Energy Grant'); + expect(entry.body).toContain('approved'); + }); + + it('stores metadata with previousValue, newValue, and changeSummary', () => { + onSubsidyStatusChanged( + db, + true, + 'sub-bafa-002', + 'BAFA Insulation Subsidy', + 'applied', + 'received', + ); + + const entries = getAllEntries(); + const metadata = JSON.parse(entries[0].metadata ?? 'null'); + expect(metadata.previousValue).toBe('applied'); + expect(metadata.newValue).toBe('received'); + expect(typeof metadata.changeSummary).toBe('string'); + }); + + it('does not create entry when enabled=false', () => { + onSubsidyStatusChanged(db, false, 'sub-001', 'Grant', 'applied', 'approved'); + expect(getAllEntries()).toHaveLength(0); + }); + }); + + // ─── Fire-and-forget behavior ────────────────────────────────────────────── + + describe('fire-and-forget behavior', () => { + it('does not propagate errors and warns via console.warn when DB write fails', () => { + // Restore the suppress-all spy and replace with one that captures calls + jest.restoreAllMocks(); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + // Close the database to force an error in createAutomaticDiaryEntry + sqlite.close(); + + // Calling any event function must NOT throw even with a broken DB + expect(() => { + onWorkItemStatusChanged(db, true, 'wi-fail', 'Failing WI', 'not_started', 'in_progress'); + }).not.toThrow(); + + // console.warn must have been called with the failure info + expect(warnSpy).toHaveBeenCalled(); + const firstCallArgs = warnSpy.mock.calls[0]; + expect(firstCallArgs[0]).toContain('[diaryAutoEvent]'); + }); + }); +}); diff --git a/server/src/services/diaryAutoEventService.ts b/server/src/services/diaryAutoEventService.ts new file mode 100644 index 000000000..1d7463ebf --- /dev/null +++ b/server/src/services/diaryAutoEventService.ts @@ -0,0 +1,219 @@ +/** + * Diary Auto Event Service — fire-and-forget event logging. + * + * Hooks into business logic services to automatically create diary entries + * when significant state changes occur (status changes, milestones, etc). + * + * All event creation is fire-and-forget: errors are logged but never propagated. + * + * EPIC-16: Story 16.3 — Automatic System Event Logging + */ + +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type * as schemaTypes from '../db/schema.js'; +import { createAutomaticDiaryEntry } from './diaryService.js'; +import type { AutoEventMetadata } from '@cornerstone/shared'; + +type DbType = BetterSQLite3Database; + +/** + * Safely create a diary entry without propagating errors. + * Logs warnings for any failures. + * + * @param db - Database connection + * @param enabled - Whether auto-events are enabled globally + * @param entryType - Automatic entry type + * @param body - Human-readable description + * @param metadata - Type-specific metadata + * @param sourceEntityType - Entity type that triggered the event (e.g., 'work_item') + * @param sourceEntityId - ID of the entity that triggered the event + */ +function tryCreateDiaryEntry( + db: DbType, + enabled: boolean, + entryType: string, + body: string, + metadata: AutoEventMetadata | null, + sourceEntityType: string | null, + sourceEntityId: string | null, +): void { + if (!enabled) return; + + try { + const entryDate = new Date().toISOString().slice(0, 10); + createAutomaticDiaryEntry(db, entryType, entryDate, body, metadata, sourceEntityType, sourceEntityId); + } catch (err) { + console.warn('[diaryAutoEvent] Failed to create diary entry', { + entryType, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Log a work item status change to the diary. + * + * @param db - Database connection + * @param enabled - Whether auto-events are enabled + * @param workItemId - ID of the work item that changed + * @param workItemTitle - Title of the work item (for reference) + * @param previousStatus - Previous status value + * @param newStatus - New status value + */ +export function onWorkItemStatusChanged( + db: DbType, + enabled: boolean, + workItemId: string, + workItemTitle: string, + previousStatus: string, + newStatus: string, +): void { + const body = `[Work Item] "${workItemTitle}" status changed from ${previousStatus} to ${newStatus}`; + const metadata: AutoEventMetadata = { + changeSummary: `Status changed from ${previousStatus} to ${newStatus}`, + previousValue: previousStatus, + newValue: newStatus, + }; + + tryCreateDiaryEntry(db, enabled, 'work_item_status', body, metadata, 'work_item', workItemId); +} + +/** + * Log an invoice status change to the diary. + * + * @param db - Database connection + * @param enabled - Whether auto-events are enabled + * @param invoiceId - ID of the invoice that changed + * @param invoiceNumber - Invoice number (for reference) + * @param previousStatus - Previous status value + * @param newStatus - New status value + */ +export function onInvoiceStatusChanged( + db: DbType, + enabled: boolean, + invoiceId: string, + invoiceNumber: string, + previousStatus: string, + newStatus: string, +): void { + const body = `[Invoice] ${invoiceNumber || 'N/A'} status changed from ${previousStatus} to ${newStatus}`; + const metadata: AutoEventMetadata = { + changeSummary: `Status changed from ${previousStatus} to ${newStatus}`, + previousValue: previousStatus, + newValue: newStatus, + }; + + tryCreateDiaryEntry(db, enabled, 'invoice_status', body, metadata, 'invoice', invoiceId); +} + +/** + * Log a milestone delay detection to the diary. + * + * @param db - Database connection + * @param enabled - Whether auto-events are enabled + * @param milestoneId - ID of the milestone + * @param milestoneName - Name of the milestone (for reference) + */ +export function onMilestoneDelayed( + db: DbType, + enabled: boolean, + milestoneId: number, + milestoneName: string, +): void { + const body = `Milestone: ${milestoneName} is delayed beyond target date`; + const metadata: AutoEventMetadata = { + changeSummary: 'Milestone delayed beyond target date', + }; + + tryCreateDiaryEntry( + db, + enabled, + 'milestone_delay', + body, + metadata, + 'milestone', + String(milestoneId), + ); +} + +/** + * Log a budget category overspend detection to the diary. + * + * @param db - Database connection + * @param enabled - Whether auto-events are enabled + * @param categoryId - ID of the budget category + * @param categoryName - Name of the category (for reference) + */ +export function onBudgetCategoryOverspend( + db: DbType, + enabled: boolean, + categoryId: string, + categoryName: string, +): void { + const body = `Budget: Category ${categoryName} has exceeded planned amount`; + const metadata: AutoEventMetadata = { + changeSummary: 'Budget category overspend detected', + }; + + tryCreateDiaryEntry( + db, + enabled, + 'budget_breach', + body, + metadata, + 'budget_source', + categoryId, + ); +} + +/** + * Log completion of automatic rescheduling to the diary. + * Only creates an entry if count > 0 (actual work items were rescheduled). + * + * @param db - Database connection + * @param enabled - Whether auto-events are enabled + * @param updatedCount - Number of work items that were rescheduled + */ +export function onAutoRescheduleCompleted( + db: DbType, + enabled: boolean, + updatedCount: number, +): void { + if (updatedCount === 0) return; + + const body = `[Schedule] Auto-reschedule completed — ${updatedCount} work item(s) updated`; + const metadata: AutoEventMetadata = { + changeSummary: `${updatedCount} work item(s) automatically rescheduled`, + itemCount: updatedCount, + }; + + tryCreateDiaryEntry(db, enabled, 'auto_reschedule', body, metadata, null, null); +} + +/** + * Log a subsidy program application status change to the diary. + * + * @param db - Database connection + * @param enabled - Whether auto-events are enabled + * @param subsidyId - ID of the subsidy program + * @param subsidyName - Name of the subsidy program (for reference) + * @param previousStatus - Previous application status + * @param newStatus - New application status + */ +export function onSubsidyStatusChanged( + db: DbType, + enabled: boolean, + subsidyId: string, + subsidyName: string, + previousStatus: string, + newStatus: string, +): void { + const body = `Subsidy: ${subsidyName} application status changed to ${newStatus}`; + const metadata: AutoEventMetadata = { + changeSummary: `Application status changed from ${previousStatus} to ${newStatus}`, + previousValue: previousStatus, + newValue: newStatus, + }; + + tryCreateDiaryEntry(db, enabled, 'subsidy_status', body, metadata, 'subsidy_program', subsidyId); +} diff --git a/server/src/services/diaryService.test.ts b/server/src/services/diaryService.test.ts index 8f8bbf2d2..119064d8c 100644 --- a/server/src/services/diaryService.test.ts +++ b/server/src/services/diaryService.test.ts @@ -352,13 +352,20 @@ describe('diaryService', () => { ); }); - it('throws ImmutableEntryError for an automatic entry', () => { + it('throws ImmutableEntryError with statusCode 403 for an automatic entry', () => { const id = insertEntry({ isAutomatic: true, entryType: 'work_item_status', createdBy: null, }); - expect(() => updateDiaryEntry(db, id, { body: 'Should fail' })).toThrow(ImmutableEntryError); + let thrown: unknown; + try { + updateDiaryEntry(db, id, { body: 'Should fail' }); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(ImmutableEntryError); + expect((thrown as ImmutableEntryError).statusCode).toBe(403); }); it('throws InvalidMetadataError for invalid metadata on update', () => { @@ -395,13 +402,15 @@ describe('diaryService', () => { ); }); - it('throws ImmutableEntryError for an automatic entry', async () => { + it('successfully deletes an automatic entry', async () => { const id = insertEntry({ isAutomatic: true, entryType: 'invoice_status', createdBy: null, }); - await expect(deleteDiaryEntry(db, id, photoStoragePath)).rejects.toThrow(ImmutableEntryError); + // Automatic entries CAN be deleted (Story #808 changed this behavior) + await expect(deleteDiaryEntry(db, id, photoStoragePath)).resolves.toBeUndefined(); + expect(() => getDiaryEntry(db, id)).toThrow(NotFoundError); }); }); diff --git a/server/src/services/diaryService.ts b/server/src/services/diaryService.ts index f57659acc..defdce66d 100644 --- a/server/src/services/diaryService.ts +++ b/server/src/services/diaryService.ts @@ -493,9 +493,7 @@ export function updateDiaryEntry( /** * Delete a diary entry and cascade-delete associated photos. - * Cannot delete automatic entries. * @throws NotFoundError if entry does not exist - * @throws ImmutableEntryError if entry is automatic */ export async function deleteDiaryEntry( db: DbType, @@ -509,11 +507,6 @@ export async function deleteDiaryEntry( throw new NotFoundError('Diary entry not found'); } - // Cannot delete automatic entries - if (entry.isAutomatic) { - throw new ImmutableEntryError(); - } - // Delete associated photos await deletePhotosForEntity(db, photoStoragePath, 'diary_entry', id); diff --git a/server/src/services/invoiceService.ts b/server/src/services/invoiceService.ts index f7ffdfc9b..1eaa0c823 100644 --- a/server/src/services/invoiceService.ts +++ b/server/src/services/invoiceService.ts @@ -16,6 +16,7 @@ import type { import { NotFoundError, ValidationError } from '../errors/AppError.js'; import { deleteLinksForEntity } from './documentLinkService.js'; import { getInvoiceBudgetLinesForInvoice } from './invoiceBudgetLineService.js'; +import { onInvoiceStatusChanged } from './diaryAutoEventService.js'; type DbType = BetterSQLite3Database; @@ -300,12 +301,19 @@ export function createInvoice( * Validates same rules as createInvoice for any provided fields. * @throws NotFoundError if vendor or invoice not found, or if invoice doesn't belong to vendor * @throws ValidationError if any provided field is invalid + * + * @param db - Database connection + * @param vendorId - Vendor ID + * @param invoiceId - Invoice ID + * @param data - Update request data + * @param diaryAutoEvents - Whether to create automatic diary entries (default: true) */ export function updateInvoice( db: DbType, vendorId: string, invoiceId: string, data: UpdateInvoiceRequest, + diaryAutoEvents: boolean = true, ): Invoice { const vendorName = assertVendorExists(db, vendorId); @@ -355,7 +363,14 @@ export function updateInvoice( updates.invoiceNumber = data.invoiceNumber; } + let statusChanged = false; + let previousStatus: string | undefined; + let newStatus: string | undefined; + if (data.status !== undefined) { + statusChanged = data.status !== existing.status; + previousStatus = existing.status; + newStatus = data.status; updates.status = data.status; } @@ -368,6 +383,18 @@ export function updateInvoice( db.update(invoices).set(updates).where(eq(invoices.id, invoiceId)).run(); + // Log status change to diary if enabled + if (statusChanged && previousStatus !== undefined && newStatus !== undefined) { + onInvoiceStatusChanged( + db, + diaryAutoEvents, + invoiceId, + existing.invoiceNumber || 'N/A', + previousStatus, + newStatus, + ); + } + const updated = db.select().from(invoices).where(eq(invoices.id, invoiceId)).get()!; return toInvoice(db, updated, vendorName); } diff --git a/server/src/services/schedulingEngine.ts b/server/src/services/schedulingEngine.ts index cc7594d5a..c9a0216ae 100644 --- a/server/src/services/schedulingEngine.ts +++ b/server/src/services/schedulingEngine.ts @@ -23,6 +23,7 @@ import { milestones, } from '../db/schema.js'; import type { ScheduleResponse, ScheduleWarning } from '@cornerstone/shared'; +import { onMilestoneDelayed } from './diaryAutoEventService.js'; // ─── Input types for the pure scheduling engine ─────────────────────────────── @@ -319,6 +320,18 @@ function buildDownstreamSet(anchorId: string, dependencies: SchedulingDependency return visited; } +// ─── Callback Options for Auto-Reschedule ───────────────────────────────────── + +/** + * Optional callbacks for autoReschedule to notify consumers of events. + */ +export interface AutoRescheduleOptions { + /** Callback when a milestone is detected as delayed beyond its target date. */ + onMilestoneDelayed?: (milestoneId: number, milestoneName: string) => void; + /** Callback when auto-reschedule completes with updated count. */ + onRescheduleCompleted?: (updatedCount: number) => void; +} + // ─── Main scheduling engine ──────────────────────────────────────────────────── /** @@ -674,9 +687,10 @@ type DbType = BetterSQLite3Database; * dependent WI and feed them into the CPM engine alongside the real dependencies. * * @param db - Drizzle database handle + * @param options - Optional callbacks for milestone delays and completion * @returns The count of work items whose dates were updated */ -export function autoReschedule(db: DbType): number { +export function autoReschedule(db: DbType, options?: AutoRescheduleOptions): number { // ── 1. Fetch all work items ────────────────────────────────────────────────── const allWorkItems = db.select().from(workItems).all(); @@ -837,8 +851,19 @@ export function autoReschedule(db: DbType): number { const now = new Date().toISOString(); for (const scheduled of result.scheduledItems) { - // Skip milestone nodes + // Process milestone nodes to detect delays if (scheduled.workItemId.startsWith('milestone:')) { + const milestoneIdStr = scheduled.workItemId.substring('milestone:'.length); + const milestoneId = parseInt(milestoneIdStr, 10); + const milestone = milestoneMap.get(milestoneId); + + if (milestone && options?.onMilestoneDelayed) { + const scheduledEnd = scheduled.scheduledEndDate; + const targetDate = milestone.targetDate; + if (scheduledEnd > targetDate) { + options.onMilestoneDelayed(milestoneId, milestone.title); + } + } continue; } @@ -1010,6 +1035,11 @@ export function autoReschedule(db: DbType): number { } } + // Invoke completion callback if provided + if (options?.onRescheduleCompleted) { + options.onRescheduleCompleted(updatedCount); + } + return updatedCount; } diff --git a/server/src/services/subsidyProgramService.ts b/server/src/services/subsidyProgramService.ts index 57f2be69c..918060f5b 100644 --- a/server/src/services/subsidyProgramService.ts +++ b/server/src/services/subsidyProgramService.ts @@ -19,6 +19,7 @@ import type { UserSummary, } from '@cornerstone/shared'; import { NotFoundError, ValidationError, SubsidyProgramInUseError } from '../errors/AppError.js'; +import { onSubsidyStatusChanged } from './diaryAutoEventService.js'; type DbType = BetterSQLite3Database; @@ -255,11 +256,17 @@ export function createSubsidyProgram( * If categoryIds is provided, replaces all existing category links. * @throws NotFoundError if program does not exist * @throws ValidationError if fields are invalid + * + * @param db - Database connection + * @param id - Subsidy program ID + * @param data - Update request data + * @param diaryAutoEvents - Whether to create automatic diary entries (default: true) */ export function updateSubsidyProgram( db: DbType, id: string, data: UpdateSubsidyProgramRequest, + diaryAutoEvents: boolean = true, ): SubsidyProgram { // Check program exists const existing = db.select().from(subsidyPrograms).where(eq(subsidyPrograms.id, id)).get(); @@ -316,6 +323,10 @@ export function updateSubsidyProgram( updates.reductionValue = data.reductionValue; } + let statusChanged = false; + let previousStatus: string | undefined; + let newStatus: string | undefined; + // Validate and add applicationStatus if provided if (data.applicationStatus !== undefined) { if (!VALID_APPLICATION_STATUSES.includes(data.applicationStatus)) { @@ -323,6 +334,9 @@ export function updateSubsidyProgram( `Invalid application status. Must be one of: ${VALID_APPLICATION_STATUSES.join(', ')}`, ); } + statusChanged = data.applicationStatus !== existing.applicationStatus; + previousStatus = existing.applicationStatus; + newStatus = data.applicationStatus; updates.applicationStatus = data.applicationStatus; } @@ -376,6 +390,11 @@ export function updateSubsidyProgram( replaceCategoryLinks(db, id, data.categoryIds); } + // Log applicationStatus change to diary if enabled + if (statusChanged && previousStatus !== undefined && newStatus !== undefined) { + onSubsidyStatusChanged(db, diaryAutoEvents, id, existing.name, previousStatus, newStatus); + } + return getSubsidyProgramById(db, id); } diff --git a/server/src/services/workItemService.ts b/server/src/services/workItemService.ts index 90026d6bf..9c31c82e4 100644 --- a/server/src/services/workItemService.ts +++ b/server/src/services/workItemService.ts @@ -14,6 +14,11 @@ import { import { listWorkItemBudgets } from './workItemBudgetService.js'; import { autoReschedule } from './schedulingEngine.js'; import { deleteLinksForEntity } from './documentLinkService.js'; +import { + onWorkItemStatusChanged, + onMilestoneDelayed, + onAutoRescheduleCompleted, +} from './diaryAutoEventService.js'; import { toUserSummary, toTagResponse } from './shared/converters.js'; import { validateTagIds } from './shared/validators.js'; import type { @@ -349,11 +354,17 @@ export function getWorkItemDetail(db: DbType, id: string): WorkItemDetail { /** * Update a work item. * Throws NotFoundError if work item does not exist. + * + * @param db - Database connection + * @param id - Work item ID + * @param data - Update request data + * @param diaryAutoEvents - Whether to create automatic diary entries (default: true) */ export function updateWorkItem( db: DbType, id: string, data: UpdateWorkItemRequest, + diaryAutoEvents: boolean = true, ): WorkItemDetail { const workItem = findWorkItemById(db, id); if (!workItem) { @@ -430,10 +441,15 @@ export function updateWorkItem( // Auto-populate actual dates on status transitions. // Only auto-populate if the actual date is currently null AND not being explicitly set // in this same request. + let statusChanged = false; + let previousStatus: string | undefined; + let newStatus: string | undefined; + if ('status' in data && data.status !== workItem.status) { const today = new Date().toISOString().slice(0, 10); - const newStatus = data.status; - const previousStatus = workItem.status; + newStatus = data.status; + previousStatus = workItem.status; + statusChanged = true; const isExplicitActualStart = 'actualStartDate' in data; const isExplicitActualEnd = 'actualEndDate' in data; @@ -493,7 +509,19 @@ export function updateWorkItem( 'status' in data; if (schedulingFieldChanged) { - autoReschedule(db); + autoReschedule(db, { + onMilestoneDelayed: (milestoneId, milestoneName) => { + onMilestoneDelayed(db, diaryAutoEvents, milestoneId, milestoneName); + }, + onRescheduleCompleted: (updatedCount) => { + onAutoRescheduleCompleted(db, diaryAutoEvents, updatedCount); + }, + }); + } + + // Log status change to diary if enabled + if (statusChanged && previousStatus !== undefined && newStatus !== undefined) { + onWorkItemStatusChanged(db, diaryAutoEvents, id, workItem.title, previousStatus, newStatus); } // Fetch and return the updated work item From e7333ec8511a1814fd7a479558561829b6246e3d Mon Sep 17 00:00:00 2001 From: "cornerstone-bot[bot]" Date: Sat, 14 Mar 2026 20:40:04 +0000 Subject: [PATCH 07/71] style: auto-fix lint and format [skip ci] --- server/src/plugins/config.ts | 4 ++- server/src/routes/workItems.ts | 7 +++- .../services/diaryAutoEventService.test.ts | 36 +++---------------- server/src/services/diaryAutoEventService.ts | 20 +++++------ 4 files changed, 23 insertions(+), 44 deletions(-) diff --git a/server/src/plugins/config.ts b/server/src/plugins/config.ts index aa60dc191..20823c920 100644 --- a/server/src/plugins/config.ts +++ b/server/src/plugins/config.ts @@ -172,7 +172,9 @@ export function loadConfig(env: Record): AppConfig { // Parse and validate DIARY_AUTO_EVENTS const diaryAutoEventsStr = (getValue('DIARY_AUTO_EVENTS') ?? 'true').toLowerCase(); if (diaryAutoEventsStr !== 'true' && diaryAutoEventsStr !== 'false') { - errors.push(`DIARY_AUTO_EVENTS must be 'true' or 'false', got: ${getValue('DIARY_AUTO_EVENTS')}`); + errors.push( + `DIARY_AUTO_EVENTS must be 'true' or 'false', got: ${getValue('DIARY_AUTO_EVENTS')}`, + ); } const diaryAutoEvents = diaryAutoEventsStr === 'true'; diff --git a/server/src/routes/workItems.ts b/server/src/routes/workItems.ts index 89b474de2..847bb4e8c 100644 --- a/server/src/routes/workItems.ts +++ b/server/src/routes/workItems.ts @@ -192,7 +192,12 @@ export default async function workItemRoutes(fastify: FastifyInstance) { const { id } = request.params as { id: string }; const data = request.body as UpdateWorkItemRequest; - const workItem = workItemService.updateWorkItem(fastify.db, id, data, fastify.config.diaryAutoEvents); + const workItem = workItemService.updateWorkItem( + fastify.db, + id, + data, + fastify.config.diaryAutoEvents, + ); return reply.status(200).send(workItem); }); diff --git a/server/src/services/diaryAutoEventService.test.ts b/server/src/services/diaryAutoEventService.test.ts index 98a964350..a12b010a3 100644 --- a/server/src/services/diaryAutoEventService.test.ts +++ b/server/src/services/diaryAutoEventService.test.ts @@ -126,14 +126,7 @@ describe('diaryAutoEventService', () => { describe('onInvoiceStatusChanged', () => { it('creates a diary entry with entryType=invoice_status, body contains invoice number', () => { - onInvoiceStatusChanged( - db, - true, - 'inv-001', - 'INV-2026-042', - 'pending', - 'paid', - ); + onInvoiceStatusChanged(db, true, 'inv-001', 'INV-2026-042', 'pending', 'paid'); const entries = getAllEntries(); expect(entries).toHaveLength(1); @@ -150,14 +143,7 @@ describe('diaryAutoEventService', () => { it('body uses invoiceNumber param (even if empty string, shows N/A)', () => { // When invoiceNumber is empty string, the service uses 'N/A' per the template - onInvoiceStatusChanged( - db, - true, - 'inv-002', - '', - 'pending', - 'claimed', - ); + onInvoiceStatusChanged(db, true, 'inv-002', '', 'pending', 'claimed'); const entries = getAllEntries(); expect(entries).toHaveLength(1); @@ -167,14 +153,7 @@ describe('diaryAutoEventService', () => { }); it('stores metadata with previousValue, newValue, and changeSummary', () => { - onInvoiceStatusChanged( - db, - true, - 'inv-003', - 'INV-2026-100', - 'pending', - 'paid', - ); + onInvoiceStatusChanged(db, true, 'inv-003', 'INV-2026-100', 'pending', 'paid'); const entries = getAllEntries(); expect(entries).toHaveLength(1); @@ -285,14 +264,7 @@ describe('diaryAutoEventService', () => { describe('onSubsidyStatusChanged', () => { it('creates a diary entry with entryType=subsidy_status, body contains subsidy name', () => { - onSubsidyStatusChanged( - db, - true, - 'sub-kfw-001', - 'KfW Energy Grant', - 'applied', - 'approved', - ); + onSubsidyStatusChanged(db, true, 'sub-kfw-001', 'KfW Energy Grant', 'applied', 'approved'); const entries = getAllEntries(); expect(entries).toHaveLength(1); diff --git a/server/src/services/diaryAutoEventService.ts b/server/src/services/diaryAutoEventService.ts index 1d7463ebf..c711f516f 100644 --- a/server/src/services/diaryAutoEventService.ts +++ b/server/src/services/diaryAutoEventService.ts @@ -41,7 +41,15 @@ function tryCreateDiaryEntry( try { const entryDate = new Date().toISOString().slice(0, 10); - createAutomaticDiaryEntry(db, entryType, entryDate, body, metadata, sourceEntityType, sourceEntityId); + createAutomaticDiaryEntry( + db, + entryType, + entryDate, + body, + metadata, + sourceEntityType, + sourceEntityId, + ); } catch (err) { console.warn('[diaryAutoEvent] Failed to create diary entry', { entryType, @@ -155,15 +163,7 @@ export function onBudgetCategoryOverspend( changeSummary: 'Budget category overspend detected', }; - tryCreateDiaryEntry( - db, - enabled, - 'budget_breach', - body, - metadata, - 'budget_source', - categoryId, - ); + tryCreateDiaryEntry(db, enabled, 'budget_breach', body, metadata, 'budget_source', categoryId); } /** From b4ffd612ca7884ff26e2f687299976b75b055949 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Sat, 14 Mar 2026 23:09:34 +0100 Subject: [PATCH 08/71] feat(diary): add entry creation, editing, and deletion UI (#815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement diary entry forms with type-specific metadata fields: - Two-step create flow: type selector (5 types) → form → submit - Edit page with pre-populated form and delete confirmation modal - DiaryEntryForm component with per-type metadata sections - Delete confirmation modal on detail and edit pages - Edit/Delete buttons on detail page (manual entries only) - Routes: /diary/new, /diary/:id/edit - Unit tests (130+ tests across 4 files) - E2E page objects and test specs (18 tests) Fixes #805 Co-authored-by: Claude product-architect (Opus 4.6) --- .../agent-memory/e2e-test-engineer/MEMORY.md | 43 + client/src/App.tsx | 22 + .../DiaryEntryForm/DiaryEntryForm.module.css | 222 +++++ .../DiaryEntryForm/DiaryEntryForm.test.tsx | 736 +++++++++++++++++ .../diary/DiaryEntryForm/DiaryEntryForm.tsx | 490 +++++++++++ .../DiaryEntryCreatePage.module.css | 212 +++++ .../DiaryEntryCreatePage.test.tsx | 356 ++++++++ .../DiaryEntryCreatePage.tsx | 324 ++++++++ .../DiaryEntryDetailPage.module.css | 193 ++++- .../DiaryEntryDetailPage.test.tsx | 12 +- .../DiaryEntryDetailPage.tsx | 141 +++- .../DiaryEntryEditPage.module.css | 273 +++++++ .../DiaryEntryEditPage.test.tsx | 568 +++++++++++++ .../DiaryEntryEditPage/DiaryEntryEditPage.tsx | 436 ++++++++++ e2e/pages/DiaryEntryCreatePage.ts | 205 +++++ e2e/pages/DiaryEntryDetailPage.ts | 65 +- e2e/pages/DiaryEntryEditPage.ts | 198 +++++ e2e/tests/diary/diary-forms.spec.ts | 760 ++++++++++++++++++ 18 files changed, 5242 insertions(+), 14 deletions(-) create mode 100644 client/src/components/diary/DiaryEntryForm/DiaryEntryForm.module.css create mode 100644 client/src/components/diary/DiaryEntryForm/DiaryEntryForm.test.tsx create mode 100644 client/src/components/diary/DiaryEntryForm/DiaryEntryForm.tsx create mode 100644 client/src/pages/DiaryEntryCreatePage/DiaryEntryCreatePage.module.css create mode 100644 client/src/pages/DiaryEntryCreatePage/DiaryEntryCreatePage.test.tsx create mode 100644 client/src/pages/DiaryEntryCreatePage/DiaryEntryCreatePage.tsx create mode 100644 client/src/pages/DiaryEntryEditPage/DiaryEntryEditPage.module.css create mode 100644 client/src/pages/DiaryEntryEditPage/DiaryEntryEditPage.test.tsx create mode 100644 client/src/pages/DiaryEntryEditPage/DiaryEntryEditPage.tsx create mode 100644 e2e/pages/DiaryEntryCreatePage.ts create mode 100644 e2e/pages/DiaryEntryEditPage.ts create mode 100644 e2e/tests/diary/diary-forms.spec.ts diff --git a/.claude/agent-memory/e2e-test-engineer/MEMORY.md b/.claude/agent-memory/e2e-test-engineer/MEMORY.md index bd9e36dd1..b35d44d0b 100644 --- a/.claude/agent-memory/e2e-test-engineer/MEMORY.md +++ b/.claude/agent-memory/e2e-test-engineer/MEMORY.md @@ -102,3 +102,46 @@ POM helper `getSuccessBannerText()` wraps `waitFor` in try/catch, returns null o This masks failures: `expect(null).toContain(X)` throws confusing error. Use: `await expect(sourcesPage.successBanner).toBeVisible()` (uses expect.timeout with retry). Also add `waitForResponse` BEFORE save click — confirms API 200 before checking UI. + +## Diary Forms E2E (Story #805, 2026-03-14) + +Files: `e2e/pages/DiaryEntryCreatePage.ts`, `e2e/pages/DiaryEntryEditPage.ts`, +`e2e/tests/diary/diary-forms.spec.ts`. DiaryEntryDetailPage.ts extended with edit/delete locators. + +Key selectors: +- Create page type cards: `getByTestId('type-card-{type}')` — clicking immediately transitions to form +- Create form: `#entry-date`, `#title`, `#body` (common); `#weather`, `#temperature`, `#workers` + (daily_log); `#inspector-name`, `#inspection-outcome` (site_visit); `#severity`, + `#resolution-status` (issue); `[name="material-input"]` (delivery) +- Create submit: `getByRole('button', { name: /Create Entry|Creating\.\.\./i })` +- Edit page: `getByRole('heading', { level: 1, name: 'Edit Diary Entry' })` +- Edit back: `getByRole('button', { name: /← Back to Entry/i })` +- Edit save: `getByRole('button', { name: /Save Changes|Saving\.\.\./i })` +- Edit delete opens modal: `getByRole('button', { name: 'Delete Entry', exact: true })` +- Detail Edit button: `getByRole('link', { name: 'Edit', exact: true })` (anchor, not button) +- Detail Delete button: `getByRole('button', { name: 'Delete', exact: true })` (NOT "Delete Entry") +- Modal: `getByRole('dialog')` — conditionally rendered; confirmDelete inside modal scope +- Confirm delete: `modal.getByRole('button', { name: /Delete Entry|Deleting\.\.\./i })` +- Edit/Delete buttons NOT rendered for automatic entries (`isAutomatic: true`) +- DiaryEntryEditPage.save() registers waitForResponse (PATCH) BEFORE click — returns after API + +## Diary E2E (Story #804, 2026-03-14) + +Files: `e2e/pages/DiaryPage.ts`, `e2e/pages/DiaryEntryDetailPage.ts`, +`e2e/tests/diary/diary-list.spec.ts`, `e2e/tests/diary/diary-detail.spec.ts`. + +Key selectors: +- DiaryPage heading: `getByRole('heading', { level: 1, name: 'Construction Diary' })` +- Filter bar: `getByTestId('diary-filter-bar')`, search: `getByTestId('diary-search-input')` +- Type switcher: `getByTestId('type-switcher-all|manual|automatic')` +- Entry cards: `getByTestId('diary-card-{id}')`, date groups: `getByTestId('date-group-{date}')` +- Type chips: `getByTestId('type-filter-{entryType}')`, clear: `getByTestId('clear-filters-button')` +- Pagination: `getByTestId('prev-page-button')` / `getByTestId('next-page-button')` +- Detail back button: `getByTitle('Go back')` (title="Go back"), back link: `getByRole('link', { name: 'Back to Diary' })` +- Metadata wrappers: `getByTestId('daily-log-metadata|site-visit-metadata|delivery-metadata|issue-metadata')` +- Outcome badge: `getByTestId('outcome-{pass|fail|conditional}')`, severity: `getByTestId('severity-{level}')` +- Automatic badge: `locator('[class*="badge"]').filter({ hasText: 'Automatic' })` + +API: `POST /api/diary-entries` returns `DiaryEntrySummary` with `id` at top level (not nested). +Empty state uses shared.emptyState CSS module class (conditional render — use `.not.toBeVisible()` not `.toBeHidden()`). +DiaryPage.waitForLoaded() races: timeline visible OR emptyState visible OR errorBanner visible. diff --git a/client/src/App.tsx b/client/src/App.tsx index 09b77879f..0089fe4e0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -69,6 +69,12 @@ const DiaryPage = lazy(() => import('./pages/DiaryPage/DiaryPage')); const DiaryEntryDetailPage = lazy( () => import('./pages/DiaryEntryDetailPage/DiaryEntryDetailPage'), ); +const DiaryEntryCreatePage = lazy( + () => import('./pages/DiaryEntryCreatePage/DiaryEntryCreatePage'), +); +const DiaryEntryEditPage = lazy( + () => import('./pages/DiaryEntryEditPage/DiaryEntryEditPage'), +); const NotFoundPage = lazy(() => import('./pages/NotFoundPage/NotFoundPage')); export function App() { @@ -152,6 +158,14 @@ export function App() { } /> + Loading...
}> + + + } + /> } /> + Loading...
}> + + + } + /> {/* Settings section */} diff --git a/client/src/components/diary/DiaryEntryForm/DiaryEntryForm.module.css b/client/src/components/diary/DiaryEntryForm/DiaryEntryForm.module.css new file mode 100644 index 000000000..2b0feda44 --- /dev/null +++ b/client/src/components/diary/DiaryEntryForm/DiaryEntryForm.module.css @@ -0,0 +1,222 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--spacing-6); +} + +/* ============================================================ + * Form Groups & Layout + * ============================================================ */ + +.formGroup { + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +.formRow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-4); +} + +.label { + display: block; + font-weight: var(--font-weight-medium); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.checkboxLabel { + display: flex; + align-items: center; + gap: var(--spacing-2); + font-weight: var(--font-weight-normal); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + cursor: pointer; +} + +.checkbox { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--color-primary); +} + +.required { + color: var(--color-danger); +} + +/* ============================================================ + * Form Inputs + * ============================================================ */ + +.input, +.select, +.textarea { + width: 100%; + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-family: inherit; + color: var(--color-text-primary); + background: var(--color-bg-primary); + transition: var(--transition-input); +} + +.input:focus-visible, +.select:focus-visible, +.textarea:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: var(--shadow-focus-subtle); +} + +.input:disabled, +.select:disabled, +.textarea:disabled { + background: var(--color-bg-secondary); + color: var(--color-text-disabled); + cursor: not-allowed; +} + +.inputError, +.selectError { + border-color: var(--color-danger); +} + +.inputError:focus-visible, +.selectError:focus-visible { + border-color: var(--color-danger); + box-shadow: var(--shadow-focus-danger); +} + +.textarea { + resize: vertical; + min-height: 120px; +} + +.textareaError { + border-color: var(--color-danger); +} + +.textareaError:focus-visible { + border-color: var(--color-danger); + box-shadow: var(--shadow-focus-danger); +} + +.charCounter { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + text-align: right; + margin-top: var(--spacing-1); +} + +.errorText { + margin-top: var(--spacing-2); + font-size: var(--font-size-sm); + color: var(--color-danger); +} + +/* ============================================================ + * Metadata Section + * ============================================================ */ + +.metadataSection { + padding: var(--spacing-4); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.metadataTitle { + margin: 0 0 var(--spacing-4) 0; + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +/* ============================================================ + * Materials List & Input (Delivery) + * ============================================================ */ + +.materialsList { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + margin-bottom: var(--spacing-3); +} + +.materialChip { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-3); + background: var(--color-primary); + color: var(--color-primary-text); + border-radius: var(--radius-full); + font-size: var(--font-size-sm); +} + +.chipRemoveButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + font-size: var(--font-size-base); + line-height: 1; + transition: opacity 0.2s; +} + +.chipRemoveButton:hover:not(:disabled) { + opacity: 0.8; +} + +.chipRemoveButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.materialInputForm { + display: flex; + gap: var(--spacing-2); +} + +.materialInputForm .input { + flex: 1; +} + +.addButton { + padding: var(--spacing-2) var(--spacing-4); + background: var(--color-primary); + color: var(--color-primary-text); + border: 1px solid var(--color-primary); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: background-color 0.2s; +} + +.addButton:hover:not(:disabled) { + background: var(--color-primary-hover); + border-color: var(--color-primary-hover); +} + +.addButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.addButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/client/src/components/diary/DiaryEntryForm/DiaryEntryForm.test.tsx b/client/src/components/diary/DiaryEntryForm/DiaryEntryForm.test.tsx new file mode 100644 index 000000000..8ea09d9fb --- /dev/null +++ b/client/src/components/diary/DiaryEntryForm/DiaryEntryForm.test.tsx @@ -0,0 +1,736 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { DiaryEntryFormProps } from './DiaryEntryForm.js'; +import type React from 'react'; + +// DiaryEntryForm has no API deps — import directly after declaring module scope +let DiaryEntryForm: React.ComponentType; + +// ── Default props factory ───────────────────────────────────────────────────── + +function makeProps(overrides: Partial = {}): DiaryEntryFormProps { + return { + entryType: 'daily_log', + entryDate: '2026-03-14', + title: '', + body: '', + onEntryDateChange: jest.fn(), + onTitleChange: jest.fn(), + onBodyChange: jest.fn(), + disabled: false, + validationErrors: {}, + ...overrides, + }; +} + +describe('DiaryEntryForm', () => { + beforeEach(async () => { + if (!DiaryEntryForm) { + const mod = await import('./DiaryEntryForm.js'); + DiaryEntryForm = mod.DiaryEntryForm; + } + }); + + // ─── Common fields ────────────────────────────────────────────────────────── + + describe('common fields', () => { + it('renders the entry date input', () => { + render(); + expect(screen.getByLabelText(/entry date/i)).toBeInTheDocument(); + }); + + it('entry date input has the correct value', () => { + render(); + const input = screen.getByLabelText(/entry date/i) as HTMLInputElement; + expect(input.value).toBe('2026-05-01'); + }); + + it('calls onEntryDateChange when entry date changes', async () => { + const onEntryDateChange = jest.fn(); + render(); + const input = screen.getByLabelText(/entry date/i); + fireEvent.change(input, { target: { value: '2026-06-01' } }); + expect(onEntryDateChange).toHaveBeenCalledWith('2026-06-01'); + }); + + it('renders the title input', () => { + render(); + expect(screen.getByLabelText(/^title$/i)).toBeInTheDocument(); + }); + + it('title input has the correct value', () => { + render(); + const input = screen.getByLabelText(/^title$/i) as HTMLInputElement; + expect(input.value).toBe('My Entry'); + }); + + it('calls onTitleChange when title changes', async () => { + const user = userEvent.setup(); + const onTitleChange = jest.fn(); + render(); + const input = screen.getByLabelText(/^title$/i); + await user.type(input, 'A'); + expect(onTitleChange).toHaveBeenCalled(); + }); + + it('renders the body textarea', () => { + render(); + expect(screen.getByRole('textbox', { name: /^entry/i })).toBeInTheDocument(); + }); + + it('body textarea has the correct value', () => { + render(); + const textarea = screen.getByRole('textbox', { name: /^entry/i }) as HTMLTextAreaElement; + expect(textarea.value).toBe('Some notes here'); + }); + + it('calls onBodyChange when body changes', async () => { + const user = userEvent.setup(); + const onBodyChange = jest.fn(); + render(); + const textarea = screen.getByRole('textbox', { name: /^entry/i }); + await user.type(textarea, 'X'); + expect(onBodyChange).toHaveBeenCalled(); + }); + }); + + // ─── Char counter ─────────────────────────────────────────────────────────── + + describe('body char counter', () => { + it('shows 0/10000 when body is empty', () => { + render(); + expect(screen.getByText('0/10000')).toBeInTheDocument(); + }); + + it('shows correct count when body has content', () => { + render(); + expect(screen.getByText('5/10000')).toBeInTheDocument(); + }); + + it('shows full count at maximum length', () => { + render(); + expect(screen.getByText('10000/10000')).toBeInTheDocument(); + }); + }); + + // ─── Validation errors ────────────────────────────────────────────────────── + + describe('validation errors', () => { + it('shows entry date validation error text when present', () => { + render( + , + ); + expect(screen.getByText('Entry date is required')).toBeInTheDocument(); + }); + + it('shows body validation error text when present', () => { + render( + , + ); + expect(screen.getByText('Entry text is required')).toBeInTheDocument(); + }); + + it('marks body textarea aria-invalid when body error is present', () => { + render( + , + ); + const textarea = screen.getByRole('textbox', { name: /^entry/i }); + expect(textarea).toHaveAttribute('aria-invalid', 'true'); + }); + + it('does not mark body textarea aria-invalid when no error', () => { + render(); + const textarea = screen.getByRole('textbox', { name: /^entry/i }); + expect(textarea).toHaveAttribute('aria-invalid', 'false'); + }); + + it('shows inspector name validation error for site_visit', () => { + render( + , + ); + expect(screen.getByText('Inspector name is required')).toBeInTheDocument(); + }); + + it('shows outcome validation error for site_visit', () => { + render( + , + ); + expect(screen.getByText('Inspection outcome is required')).toBeInTheDocument(); + }); + + it('shows severity validation error for issue', () => { + render( + , + ); + expect(screen.getByText('Severity is required')).toBeInTheDocument(); + }); + + it('shows resolution status validation error for issue', () => { + render( + , + ); + expect(screen.getByText('Resolution status is required')).toBeInTheDocument(); + }); + }); + + // ─── disabled state ────────────────────────────────────────────────────────── + + describe('disabled state', () => { + it('disables the entry date input when disabled=true', () => { + render(); + expect(screen.getByLabelText(/entry date/i)).toBeDisabled(); + }); + + it('disables the title input when disabled=true', () => { + render(); + expect(screen.getByLabelText(/^title$/i)).toBeDisabled(); + }); + + it('disables the body textarea when disabled=true', () => { + render(); + expect(screen.getByRole('textbox', { name: /^entry/i })).toBeDisabled(); + }); + + it('disables the weather select when disabled=true (daily_log)', () => { + render(); + expect(screen.getByLabelText(/weather/i)).toBeDisabled(); + }); + + it('disables the delivery vendor input when disabled=true (delivery)', () => { + render(); + expect(screen.getByLabelText(/^vendor$/i)).toBeDisabled(); + }); + }); + + // ─── daily_log metadata ───────────────────────────────────────────────────── + + describe('daily_log metadata section', () => { + it('shows "Daily Log Details" section heading', () => { + render(); + expect(screen.getByText('Daily Log Details')).toBeInTheDocument(); + }); + + it('renders the weather select', () => { + render(); + expect(screen.getByLabelText(/weather/i)).toBeInTheDocument(); + }); + + it('weather select has all options', () => { + render(); + const select = screen.getByLabelText(/weather/i) as HTMLSelectElement; + const optionValues = Array.from(select.options).map((o) => o.value); + expect(optionValues).toContain('sunny'); + expect(optionValues).toContain('cloudy'); + expect(optionValues).toContain('rainy'); + expect(optionValues).toContain('snowy'); + expect(optionValues).toContain('stormy'); + expect(optionValues).toContain('other'); + }); + + it('shows the current weather value', () => { + render( + , + ); + const select = screen.getByLabelText(/weather/i) as HTMLSelectElement; + expect(select.value).toBe('sunny'); + }); + + it('calls onDailyLogWeatherChange when weather is changed', () => { + const onDailyLogWeatherChange = jest.fn(); + render( + , + ); + const select = screen.getByLabelText(/weather/i); + fireEvent.change(select, { target: { value: 'rainy' } }); + expect(onDailyLogWeatherChange).toHaveBeenCalledWith('rainy'); + }); + + it('renders the temperature input', () => { + render(); + expect(screen.getByLabelText(/temperature/i)).toBeInTheDocument(); + }); + + it('shows the current temperature value', () => { + render( + , + ); + const input = screen.getByLabelText(/temperature/i) as HTMLInputElement; + expect(input.value).toBe('22'); + }); + + it('calls onDailyLogTemperatureChange when temperature changes', () => { + const onDailyLogTemperatureChange = jest.fn(); + render( + , + ); + const input = screen.getByLabelText(/temperature/i); + fireEvent.change(input, { target: { value: '15' } }); + expect(onDailyLogTemperatureChange).toHaveBeenCalledWith(15); + }); + + it('calls onDailyLogTemperatureChange with null when cleared', () => { + const onDailyLogTemperatureChange = jest.fn(); + render( + , + ); + const input = screen.getByLabelText(/temperature/i); + fireEvent.change(input, { target: { value: '' } }); + expect(onDailyLogTemperatureChange).toHaveBeenCalledWith(null); + }); + + it('renders the workers on site input', () => { + render(); + expect(screen.getByLabelText(/workers on site/i)).toBeInTheDocument(); + }); + + it('shows the current workers value', () => { + render( + , + ); + const input = screen.getByLabelText(/workers on site/i) as HTMLInputElement; + expect(input.value).toBe('7'); + }); + + it('calls onDailyLogWorkersChange when workers changes', () => { + const onDailyLogWorkersChange = jest.fn(); + render( + , + ); + const input = screen.getByLabelText(/workers on site/i); + fireEvent.change(input, { target: { value: '3' } }); + expect(onDailyLogWorkersChange).toHaveBeenCalledWith(3); + }); + }); + + // ─── site_visit metadata ──────────────────────────────────────────────────── + + describe('site_visit metadata section', () => { + it('shows "Site Visit Details" section heading', () => { + render(); + expect(screen.getByText('Site Visit Details')).toBeInTheDocument(); + }); + + it('renders the inspector name input with required marker', () => { + render(); + expect(screen.getByLabelText(/inspector name/i)).toBeInTheDocument(); + }); + + it('inspector name input has required attribute', () => { + render(); + expect(screen.getByLabelText(/inspector name/i)).toHaveAttribute('required'); + }); + + it('shows the current inspector name value', () => { + render( + , + ); + const input = screen.getByLabelText(/inspector name/i) as HTMLInputElement; + expect(input.value).toBe('Jane Doe'); + }); + + it('calls onSiteVisitInspectorNameChange when name changes', () => { + const onSiteVisitInspectorNameChange = jest.fn(); + render( + , + ); + const input = screen.getByLabelText(/inspector name/i); + fireEvent.change(input, { target: { value: 'Bob Smith' } }); + expect(onSiteVisitInspectorNameChange).toHaveBeenCalledWith('Bob Smith'); + }); + + it('renders the inspection outcome select with required attribute', () => { + render(); + const select = screen.getByLabelText(/inspection outcome/i); + expect(select).toBeInTheDocument(); + expect(select).toHaveAttribute('required'); + }); + + it('outcome select has pass, fail, conditional options', () => { + render(); + const select = screen.getByLabelText(/inspection outcome/i) as HTMLSelectElement; + const optionValues = Array.from(select.options).map((o) => o.value); + expect(optionValues).toContain('pass'); + expect(optionValues).toContain('fail'); + expect(optionValues).toContain('conditional'); + }); + + it('shows the current outcome value', () => { + render( + , + ); + const select = screen.getByLabelText(/inspection outcome/i) as HTMLSelectElement; + expect(select.value).toBe('pass'); + }); + + it('calls onSiteVisitOutcomeChange when outcome changes', () => { + const onSiteVisitOutcomeChange = jest.fn(); + render( + , + ); + const select = screen.getByLabelText(/inspection outcome/i); + fireEvent.change(select, { target: { value: 'fail' } }); + expect(onSiteVisitOutcomeChange).toHaveBeenCalledWith('fail'); + }); + }); + + // ─── delivery metadata ────────────────────────────────────────────────────── + + describe('delivery metadata section', () => { + it('shows "Delivery Details" section heading', () => { + render(); + expect(screen.getByText('Delivery Details')).toBeInTheDocument(); + }); + + it('renders the vendor input', () => { + render(); + expect(screen.getByLabelText(/^vendor$/i)).toBeInTheDocument(); + }); + + it('shows the current vendor value', () => { + render( + , + ); + const input = screen.getByLabelText(/^vendor$/i) as HTMLInputElement; + expect(input.value).toBe('ACME Corp'); + }); + + it('calls onDeliveryVendorChange when vendor changes', () => { + const onDeliveryVendorChange = jest.fn(); + render( + , + ); + const input = screen.getByLabelText(/^vendor$/i); + fireEvent.change(input, { target: { value: 'Supplier X' } }); + expect(onDeliveryVendorChange).toHaveBeenCalledWith('Supplier X'); + }); + + it('renders the delivery confirmed checkbox', () => { + render(); + expect(screen.getByLabelText(/delivery confirmed/i)).toBeInTheDocument(); + }); + + it('checkbox reflects deliveryConfirmed=true', () => { + render( + , + ); + const checkbox = screen.getByLabelText(/delivery confirmed/i) as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it('calls onDeliveryConfirmedChange when checkbox toggled', () => { + const onDeliveryConfirmedChange = jest.fn(); + render( + , + ); + const checkbox = screen.getByLabelText(/delivery confirmed/i); + fireEvent.click(checkbox); + expect(onDeliveryConfirmedChange).toHaveBeenCalledWith(true); + }); + + it('renders the Add button for materials', () => { + render(); + expect(screen.getByRole('button', { name: /^add$/i })).toBeInTheDocument(); + }); + + it('renders existing material chips', () => { + render( + , + ); + expect(screen.getByText('Concrete')).toBeInTheDocument(); + expect(screen.getByText('Steel beams')).toBeInTheDocument(); + }); + + it('renders remove buttons for each material chip', () => { + render( + , + ); + expect(screen.getByRole('button', { name: /remove lumber/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /remove nails/i })).toBeInTheDocument(); + }); + + it('calls onDeliveryMaterialsChange without the item when remove is clicked', () => { + const onDeliveryMaterialsChange = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /remove lumber/i })); + expect(onDeliveryMaterialsChange).toHaveBeenCalledWith(['Nails']); + }); + + it('calls onDeliveryMaterialsChange with null when last material is removed', () => { + const onDeliveryMaterialsChange = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /remove lumber/i })); + expect(onDeliveryMaterialsChange).toHaveBeenCalledWith(null); + }); + + it('adds a material via the form input and Add button', async () => { + const user = userEvent.setup(); + const onDeliveryMaterialsChange = jest.fn(); + render( + , + ); + const materialInput = screen.getByPlaceholderText(/add material and press enter/i); + await user.type(materialInput, 'Rebar'); + await user.click(screen.getByRole('button', { name: /^add$/i })); + expect(onDeliveryMaterialsChange).toHaveBeenCalledWith(['Rebar']); + }); + + it('does not add material when input is blank', async () => { + const user = userEvent.setup(); + const onDeliveryMaterialsChange = jest.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: /^add$/i })); + expect(onDeliveryMaterialsChange).not.toHaveBeenCalled(); + }); + + it('appends material to existing list', async () => { + const user = userEvent.setup(); + const onDeliveryMaterialsChange = jest.fn(); + render( + , + ); + const materialInput = screen.getByPlaceholderText(/add material and press enter/i); + await user.type(materialInput, 'Nails'); + await user.click(screen.getByRole('button', { name: /^add$/i })); + expect(onDeliveryMaterialsChange).toHaveBeenCalledWith(['Lumber', 'Nails']); + }); + + it('disables Add button when disabled=true', () => { + render(); + expect(screen.getByRole('button', { name: /^add$/i })).toBeDisabled(); + }); + }); + + // ─── issue metadata ───────────────────────────────────────────────────────── + + describe('issue metadata section', () => { + it('shows "Issue Details" section heading', () => { + render(); + expect(screen.getByText('Issue Details')).toBeInTheDocument(); + }); + + it('renders the severity select with required attribute', () => { + render(); + const select = screen.getByLabelText(/severity/i); + expect(select).toBeInTheDocument(); + expect(select).toHaveAttribute('required'); + }); + + it('severity select has low, medium, high, critical options', () => { + render(); + const select = screen.getByLabelText(/severity/i) as HTMLSelectElement; + const optionValues = Array.from(select.options).map((o) => o.value); + expect(optionValues).toContain('low'); + expect(optionValues).toContain('medium'); + expect(optionValues).toContain('high'); + expect(optionValues).toContain('critical'); + }); + + it('shows the current severity value', () => { + render( + , + ); + const select = screen.getByLabelText(/severity/i) as HTMLSelectElement; + expect(select.value).toBe('high'); + }); + + it('calls onIssueSeverityChange when severity changes', () => { + const onIssueSeverityChange = jest.fn(); + render( + , + ); + const select = screen.getByLabelText(/severity/i); + fireEvent.change(select, { target: { value: 'critical' } }); + expect(onIssueSeverityChange).toHaveBeenCalledWith('critical'); + }); + + it('renders the resolution status select with required attribute', () => { + render(); + const select = screen.getByLabelText(/resolution status/i); + expect(select).toBeInTheDocument(); + expect(select).toHaveAttribute('required'); + }); + + it('resolution status select has open, in_progress, resolved options', () => { + render(); + const select = screen.getByLabelText(/resolution status/i) as HTMLSelectElement; + const optionValues = Array.from(select.options).map((o) => o.value); + expect(optionValues).toContain('open'); + expect(optionValues).toContain('in_progress'); + expect(optionValues).toContain('resolved'); + }); + + it('shows the current resolution status value', () => { + render( + , + ); + const select = screen.getByLabelText(/resolution status/i) as HTMLSelectElement; + expect(select.value).toBe('in_progress'); + }); + + it('calls onIssueResolutionStatusChange when status changes', () => { + const onIssueResolutionStatusChange = jest.fn(); + render( + , + ); + const select = screen.getByLabelText(/resolution status/i); + fireEvent.change(select, { target: { value: 'resolved' } }); + expect(onIssueResolutionStatusChange).toHaveBeenCalledWith('resolved'); + }); + }); + + // ─── general_note — no metadata section ───────────────────────────────────── + + describe('general_note type', () => { + it('does not render any type-specific metadata section', () => { + render(); + expect(screen.queryByText('Daily Log Details')).not.toBeInTheDocument(); + expect(screen.queryByText('Site Visit Details')).not.toBeInTheDocument(); + expect(screen.queryByText('Delivery Details')).not.toBeInTheDocument(); + expect(screen.queryByText('Issue Details')).not.toBeInTheDocument(); + }); + + it('still renders date, title, body fields', () => { + render(); + expect(screen.getByLabelText(/entry date/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^title$/i)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /^entry/i })).toBeInTheDocument(); + }); + }); + + // ─── metadata sections are exclusive ──────────────────────────────────────── + + describe('type exclusivity', () => { + it('daily_log does not show site_visit section', () => { + render(); + expect(screen.queryByText('Site Visit Details')).not.toBeInTheDocument(); + }); + + it('site_visit does not show daily_log section', () => { + render(); + expect(screen.queryByText('Daily Log Details')).not.toBeInTheDocument(); + }); + + it('delivery does not show issue section', () => { + render(); + expect(screen.queryByText('Issue Details')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/diary/DiaryEntryForm/DiaryEntryForm.tsx b/client/src/components/diary/DiaryEntryForm/DiaryEntryForm.tsx new file mode 100644 index 000000000..9b99fb70b --- /dev/null +++ b/client/src/components/diary/DiaryEntryForm/DiaryEntryForm.tsx @@ -0,0 +1,490 @@ +import React from 'react'; +import type { + ManualDiaryEntryType, + DiaryWeather, + DiaryInspectionOutcome, + DiaryIssueSeverity, + DiaryIssueResolution, + DailyLogMetadata, + SiteVisitMetadata, + DeliveryMetadata, + IssueMetadata, +} from '@cornerstone/shared'; +import styles from './DiaryEntryForm.module.css'; + +export interface DiaryEntryFormProps { + entryType: ManualDiaryEntryType; + entryDate: string; + title: string; + body: string; + onEntryDateChange: (date: string) => void; + onTitleChange: (title: string) => void; + onBodyChange: (body: string) => void; + disabled?: boolean; + validationErrors: Record; + /** daily_log metadata */ + dailyLogWeather?: DiaryWeather | null; + onDailyLogWeatherChange?: (weather: DiaryWeather | null) => void; + dailyLogTemperature?: number | null; + onDailyLogTemperatureChange?: (temp: number | null) => void; + dailyLogWorkers?: number | null; + onDailyLogWorkersChange?: (workers: number | null) => void; + /** site_visit metadata */ + siteVisitInspectorName?: string | null; + onSiteVisitInspectorNameChange?: (name: string | null) => void; + siteVisitOutcome?: DiaryInspectionOutcome | null; + onSiteVisitOutcomeChange?: (outcome: DiaryInspectionOutcome | null) => void; + /** delivery metadata */ + deliveryVendor?: string | null; + onDeliveryVendorChange?: (vendor: string | null) => void; + deliveryMaterials?: string[] | null; + onDeliveryMaterialsChange?: (materials: string[] | null) => void; + deliveryConfirmed?: boolean; + onDeliveryConfirmedChange?: (confirmed: boolean) => void; + /** issue metadata */ + issueSeverity?: DiaryIssueSeverity | null; + onIssueSeverityChange?: (severity: DiaryIssueSeverity | null) => void; + issueResolutionStatus?: DiaryIssueResolution | null; + onIssueResolutionStatusChange?: (status: DiaryIssueResolution | null) => void; +} + +const WEATHER_OPTIONS: Array<{ value: DiaryWeather; label: string }> = [ + { value: 'sunny', label: 'Sunny' }, + { value: 'cloudy', label: 'Cloudy' }, + { value: 'rainy', label: 'Rainy' }, + { value: 'snowy', label: 'Snowy' }, + { value: 'stormy', label: 'Stormy' }, + { value: 'other', label: 'Other' }, +]; + +const INSPECTION_OUTCOME_OPTIONS: Array<{ value: DiaryInspectionOutcome; label: string }> = [ + { value: 'pass', label: 'Pass' }, + { value: 'fail', label: 'Fail' }, + { value: 'conditional', label: 'Conditional' }, +]; + +const SEVERITY_OPTIONS: Array<{ value: DiaryIssueSeverity; label: string }> = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'critical', label: 'Critical' }, +]; + +const RESOLUTION_STATUS_OPTIONS: Array<{ value: DiaryIssueResolution; label: string }> = [ + { value: 'open', label: 'Open' }, + { value: 'in_progress', label: 'In Progress' }, + { value: 'resolved', label: 'Resolved' }, +]; + +export function DiaryEntryForm({ + entryType, + entryDate, + title, + body, + onEntryDateChange, + onTitleChange, + onBodyChange, + disabled = false, + validationErrors, + // daily_log + dailyLogWeather, + onDailyLogWeatherChange, + dailyLogTemperature, + onDailyLogTemperatureChange, + dailyLogWorkers, + onDailyLogWorkersChange, + // site_visit + siteVisitInspectorName, + onSiteVisitInspectorNameChange, + siteVisitOutcome, + onSiteVisitOutcomeChange, + // delivery + deliveryVendor, + onDeliveryVendorChange, + deliveryMaterials, + onDeliveryMaterialsChange, + deliveryConfirmed, + onDeliveryConfirmedChange, + // issue + issueSeverity, + onIssueSeverityChange, + issueResolutionStatus, + onIssueResolutionStatusChange, +}: DiaryEntryFormProps) { + const materialInputRef = React.useRef(null); + + const handleAddMaterial = () => { + const input = materialInputRef.current; + if (!input) return; + const newMaterial = input.value.trim(); + if (newMaterial && onDeliveryMaterialsChange) { + const updated = [...(deliveryMaterials || []), newMaterial]; + onDeliveryMaterialsChange(updated); + input.value = ''; + } + }; + + const handleRemoveMaterial = (index: number) => { + if (onDeliveryMaterialsChange && deliveryMaterials) { + const updated = deliveryMaterials.filter((_, i) => i !== index); + onDeliveryMaterialsChange(updated.length > 0 ? updated : null); + } + }; + + return ( +
+ {/* Common fields */} +
+ + onEntryDateChange(e.target.value)} + disabled={disabled} + required + aria-invalid={!!validationErrors.entryDate} + aria-describedby={validationErrors.entryDate ? 'entry-date-error' : undefined} + /> + {validationErrors.entryDate && ( + + )} +
+ +
+ + onTitleChange(e.target.value)} + disabled={disabled} + placeholder="Optional title for this entry" + maxLength={200} + /> +
+ +
+ +