diff --git a/.github/workflows/policy.yaml b/.github/workflows/policy.yaml deleted file mode 100644 index 8603b05..0000000 --- a/.github/workflows/policy.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: PR Policy - -on: - pull_request: - types: - - opened - - reopened - - synchronize - - edited - - labeled - - unlabeled - - ready_for_review - -concurrency: - group: "policy-${{ github.event.pull_request.number }}" - cancel-in-progress: true - -permissions: - contents: read - issues: read - pull-requests: read - -jobs: - policy: - uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@e5691d8a7f96ac4d5a841a82975ff0a4354d53ac # v1.0.7 - secrets: - JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }} - JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }} - JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} diff --git a/e2e/dashboard-grids.spec.ts b/e2e/dashboard-grids.spec.ts index 160476a..29bc24c 100644 --- a/e2e/dashboard-grids.spec.ts +++ b/e2e/dashboard-grids.spec.ts @@ -61,12 +61,12 @@ test.describe("dashboard grids", () => { await editor.fill("2026-09-01"); await editor.press("Enter"); - await expect(payDateCell).toContainText("Sep 1, 2026"); + await expect(payDateCell).toContainText("09/01/2026"); await page.getByTestId("tab-pending").click(); await page.getByTestId("tab-ready").click(); await expect( page.getByTestId("grid-ready").locator('[data-field="payDate"]').nth(1), - ).toContainText("Sep 1, 2026"); + ).toContainText("09/01/2026"); }); }); diff --git a/src/dashboard/columns.test.ts b/src/dashboard/columns.test.ts index 3688c34..a0cdeb1 100644 --- a/src/dashboard/columns.test.ts +++ b/src/dashboard/columns.test.ts @@ -12,4 +12,17 @@ describe("invoice column date helpers (AP-47)", () => { expect(formatRelativeFromIsoDate("2026-08-10", now)).toBe("1 day ago"); expect(formatRelativeFromIsoDate("2026-02-11", now)).toBe("6 months ago"); }); + + it("uses calendar days so early hours still treat yesterday as 1 day ago", () => { + const earlyMorning = new Date("2026-08-11T01:15:00"); + expect(formatRelativeFromIsoDate("2026-08-10", earlyMorning)).toBe("1 day ago"); + expect(formatRelativeFromIsoDate("2026-08-11", earlyMorning)).toBe("today"); + }); + + it("formats future invoice and due dates instead of clamping to today", () => { + const now = new Date("2026-08-11T12:00:00"); + expect(formatRelativeFromIsoDate("2026-08-12", now)).toBe("in 1 day"); + expect(formatRelativeFromIsoDate("2026-08-16", now)).toBe("in 5 days"); + expect(formatRelativeFromIsoDate("2026-10-11", now)).toBe("in 2 months"); + }); }); diff --git a/src/dashboard/invoice-date-format.ts b/src/dashboard/invoice-date-format.ts index a3ff5e3..f02ade0 100644 --- a/src/dashboard/invoice-date-format.ts +++ b/src/dashboard/invoice-date-format.ts @@ -1,3 +1,12 @@ +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function formatUnit(count: number, unit: "day" | "month" | "year", future: boolean): string { + const noun = count === 1 ? unit : `${unit}s`; + return future ? `in ${count} ${noun}` : `${count} ${noun} ago`; +} + export function formatSlashDate(value: string | null | undefined): string { if (!value) { return "—"; @@ -24,19 +33,24 @@ export function formatRelativeFromIsoDate( if (Number.isNaN(parsed.getTime())) { return ""; } - const diffMs = now.getTime() - parsed.getTime(); + const dayMs = 24 * 60 * 60 * 1000; - const days = Math.max(0, Math.floor(diffMs / dayMs)); - if (days < 1) { + const days = Math.round( + (startOfLocalDay(now).getTime() - startOfLocalDay(parsed).getTime()) / dayMs, + ); + if (days === 0) { return "today"; } - if (days < 30) { - return days === 1 ? "1 day ago" : `${days} days ago`; + + const future = days < 0; + const absDays = Math.abs(days); + if (absDays < 30) { + return formatUnit(absDays, "day", future); } - const months = Math.floor(days / 30); + const months = Math.floor(absDays / 30); if (months < 12) { - return months === 1 ? "1 month ago" : `${months} months ago`; + return formatUnit(months, "month", future); } const years = Math.floor(months / 12); - return years === 1 ? "1 year ago" : `${years} years ago`; + return formatUnit(years, "year", future); } diff --git a/src/mocks/index.test.ts b/src/mocks/index.test.ts index 4edda0d..774dcd9 100644 --- a/src/mocks/index.test.ts +++ b/src/mocks/index.test.ts @@ -81,6 +81,8 @@ describe("mocks module", () => { } const pending = invoices.filter((invoice) => invoice.status === "pending_approval"); expect(pending.every((invoice) => invoice.actionLabel === "Approve invoice")).toBe(true); + const nonPending = invoices.filter((invoice) => invoice.status !== "pending_approval"); + expect(nonPending.every((invoice) => invoice.actionLabel !== "Approve invoice")).toBe(true); }); it("seeds at least two invoices per dashboard tab", () => { @@ -123,6 +125,7 @@ describe("mocks module", () => { const updated = updateMockInvoiceHeader("inv-2", { memo: "Updated memo", amountDue: 4700 }); expect(updated.memo).toBe("Updated memo"); expect(updated.amountDue).toBe(4700); + expect(updated.description).toBe("Updated memo"); const withLines = updateMockInvoiceLines("inv-2", [ { @@ -135,7 +138,9 @@ describe("mocks module", () => { ]); expect(withLines.lines).toHaveLength(1); expect(withLines.lines[0].description).toBe("Updated line"); + expect(withLines.rowCount).toBe(1); expect(getMockInvoice("inv-2")?.lines[0].description).toBe("Updated line"); + expect(getMockInvoice("inv-2")?.rowCount).toBe(1); }); it("adds comments and transitions status with activity", () => { @@ -150,13 +155,40 @@ describe("mocks module", () => { actorUserId: "user-2", actorName: "Jordan Lee", message: "Approved", + createdAt: "2026-08-12T09:00:00.000Z", }); expect(approved.status).toBe("approved"); expect(approved.activity.some((entry) => entry.message === "Approved")).toBe(true); + expect(approved.lastUpdateMessage).toBe("Approved"); + expect(approved.lastUpdateActorName).toBe("Jordan Lee"); + expect(approved.lastUpdateAt).toBe("2026-08-12T09:00:00.000Z"); + expect(approved.lastUpdate).toBe("2026-08-12"); + expect(approved.actionLabel).not.toBe("Approve invoice"); expect(getMockPendingApprovals().some((item) => item.invoiceId === "inv-2")).toBe(false); expect(getMockApprovals().find((item) => item.invoiceId === "inv-2")?.status).toBe("approved"); }); + it("refreshes description and rowCount when header or lines change", () => { + const before = getMockInvoice("inv-1"); + expect(before?.rowCount).toBe(2); + expect(before?.description).toBe("Office supplies restock"); + + const header = updateMockInvoiceHeader("inv-1", { memo: "Revised supplies memo" }); + expect(header.description).toBe("Revised supplies memo"); + + const lines = updateMockInvoiceLines("inv-1", [ + { + id: "inv-1-line-1", + description: "Only line", + amount: 100, + glAccountId: "gl-1", + departmentId: "dept-4", + }, + ]); + expect(lines.rowCount).toBe(1); + expect(getMockInvoice("inv-1")?.rowCount).toBe(1); + }); + it("returns undefined for unknown invoice ids", () => { expect(getMockInvoice("missing-invoice")).toBeUndefined(); }); diff --git a/src/mocks/invoices.ts b/src/mocks/invoices.ts index 48eb505..81cc2b5 100644 --- a/src/mocks/invoices.ts +++ b/src/mocks/invoices.ts @@ -104,36 +104,67 @@ type MockInvoiceCore = Omit< | "rowCount" >; -function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { - const actionLabel = - core.status === "pending_approval" - ? "Approve invoice" - : INVOICE_ACTION_OPTIONS[index % INVOICE_ACTION_OPTIONS.length]; - const latestActivity = core.activity[core.activity.length - 1]; - const lastUpdateAt = latestActivity?.createdAt ?? `${core.dueDate}T12:00:00.000Z`; +/** Non-pending rows never show the pending-only "Approve invoice" action. */ +const NON_PENDING_ACTION_OPTIONS = INVOICE_ACTION_OPTIONS.filter( + (option) => option !== "Approve invoice", +); + +function actionLabelForStatus(status: MockInvoiceStatus, index: number): string { + if (status === "pending_approval") { + return "Approve invoice"; + } + return NON_PENDING_ACTION_OPTIONS[index % NON_PENDING_ACTION_OPTIONS.length]; +} + +function derivedActivityFields(invoice: Pick) { + const latestActivity = invoice.activity[invoice.activity.length - 1]; + const lastUpdateAt = latestActivity?.createdAt ?? `${invoice.dueDate}T12:00:00.000Z`; return { - ...core, - hasBolt: index % 3 === 0, - hasBell: index % 2 === 0, - hasEye: true, - actionLabel, lastUpdate: lastUpdateAt.slice(0, 10), lastUpdateMessage: latestActivity?.message ?? "Invoice updated", lastUpdateActorName: latestActivity?.actorName ?? "System", lastUpdateAt, - invoiceDate: core.dueDate, - processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, - tagging: index % 2 === 0 ? "Ops" : "", - fs: index % 2 === 0 ? "synced" : "", - description: core.memo, - invoiceType: "Standard", - location: "Ronkonkoma", - paymentAccount: core.paymentMethod === "ach" ? "Operating ACH" : "Operating Check", - relatedInvoice: null, - rowCount: core.lines.length, }; } +/** Keep catalog-derived fields in sync after mock mutations. */ +function withRefreshedCatalogFields(invoice: MockInvoice, index: number): MockInvoice { + return { + ...invoice, + actionLabel: actionLabelForStatus(invoice.status, index), + ...derivedActivityFields(invoice), + description: invoice.memo, + rowCount: invoice.lines.length, + }; +} + +function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice { + return withRefreshedCatalogFields( + { + ...core, + hasBolt: index % 3 === 0, + hasBell: index % 2 === 0, + hasEye: true, + actionLabel: actionLabelForStatus(core.status, index), + lastUpdate: "", + lastUpdateMessage: "", + lastUpdateActorName: "", + lastUpdateAt: "", + invoiceDate: core.dueDate, + processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate, + tagging: index % 2 === 0 ? "Ops" : "", + fs: index % 2 === 0 ? "synced" : "", + description: core.memo, + invoiceType: "Standard", + location: "Ronkonkoma", + paymentAccount: core.paymentMethod === "ach" ? "Operating ACH" : "Operating Check", + relatedInvoice: null, + rowCount: core.lines.length, + }, + index, + ); +} + function cloneInvoice(invoice: MockInvoice): MockInvoice { return { ...invoice, @@ -527,7 +558,10 @@ export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatc throw new Error(`Mock invoice not found: ${id}`); } - return replaceInvoice(index, { ...invoiceStore[index], ...patch }); + return replaceInvoice( + index, + withRefreshedCatalogFields({ ...invoiceStore[index], ...patch }, index), + ); } export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): MockInvoice { @@ -536,10 +570,16 @@ export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): Mo throw new Error(`Mock invoice not found: ${id}`); } - return replaceInvoice(index, { - ...invoiceStore[index], - lines: lines.map((line) => ({ ...line })), - }); + return replaceInvoice( + index, + withRefreshedCatalogFields( + { + ...invoiceStore[index], + lines: lines.map((line) => ({ ...line })), + }, + index, + ), + ); } export function addMockInvoiceComment( @@ -591,11 +631,17 @@ export function transitionMockInvoiceStatus( createdAt: activity.createdAt ?? new Date().toISOString(), }; - const updated = replaceInvoice(index, { - ...current, - status, - activity: [...current.activity, nextActivity], - }); + const updated = replaceInvoice( + index, + withRefreshedCatalogFields( + { + ...current, + status, + activity: [...current.activity, nextActivity], + }, + index, + ), + ); if (status === "approved" || status === "rejected") { syncMockApprovalsForInvoice(id, status);