diff --git a/index.html b/index.html index 994a834..c87f626 100644 --- a/index.html +++ b/index.html @@ -57,7 +57,7 @@

Cashflow Simulator

- v0.6.2 + v0.6.3
+ + +
+
+
+

+ Results ({{ filteredResults.length }} entries) +

+ +
+
+ + + + + + + + + + + + + + + + + +
Date + Cashflow + BalanceItems
+ {{ formatDate(row.date) }} + + {{ row.cashflow >= 0 ? '+' : '' }}{{ row.cashflow.toFixed(2) }} + + {{ row.balance.toFixed(2) }} + + {{ row.items.join(', ') || '—' }} +
+
+
+
+
+
@@ -527,6 +591,20 @@

Results ({{ filteredResults.length }} entries)

+
+ + +
@@ -652,6 +730,24 @@

simEnd.value = formatLocalDate(end); } + /** + * Add one month to a YYYY-MM-DD string, clamping the day to the + * target month's length (2027-01-31 → 2027-02-28). + * @param {string} iso - Date in YYYY-MM-DD format + * @returns {string} + */ + function addOneMonthISO(iso) { + const parts = iso.split('-').map(Number); + const y = /** @type {number} */ (parts[0]); + const m = /** @type {number} */ (parts[1]); + const d = /** @type {number} */ (parts[2]); + const nextYear = m === 12 ? y + 1 : y; + const nextMonth = m === 12 ? 1 : m + 1; + const daysInMonth = new Date(Date.UTC(nextYear, nextMonth, 0)).getUTCDate(); + const nextDay = String(Math.min(d, daysInMonth)).padStart(2, '0'); + return `${nextYear}-${String(nextMonth).padStart(2, '0')}-${nextDay}`; + } + // Expand simulation period to cover all current events function expandSimPeriodForEvents() { if (events.value.length === 0) return; @@ -678,10 +774,19 @@

if (earliestStart && earliestStart < simStart.value) { simStart.value = earliestStart; } - // Expand end: prefer latestEnd, fallback to today+1year if events have no endDate - if (latestEnd && latestEnd > simEnd.value) { - simEnd.value = latestEnd; - } else if (!latestEnd || latestEnd < todayStr) { + // Expand end to cover bounded events. When open-ended events + // exist, extend one month past the last bounded end so their + // recurrence stays visible beyond it — otherwise the whole + // simulation would stop at that event's endDate (e.g. a loan + // ending mid-salary-cycle would hide the next salary payment). + const hasOpenEnded = events.value.some(ev => ev.startDate && !ev.endDate); + let desiredEnd = latestEnd; + if (hasOpenEnded && desiredEnd) { + desiredEnd = addOneMonthISO(desiredEnd); + } + if (desiredEnd && desiredEnd > simEnd.value) { + simEnd.value = desiredEnd; + } else if (!desiredEnd || desiredEnd < todayStr) { simEnd.value = oneYearLater; } simPeriodPreset.value = 'custom'; @@ -782,6 +887,8 @@

// Chart fullscreen const chartFullscreen = ref(false); + // Results table fullscreen + const resultsFullscreen = ref(false); // --- Auto-run on mount --- onMounted(() => { @@ -801,6 +908,8 @@

cancelEditEvent(); } else if (chartFullscreen.value) { chartFullscreen.value = false; + } else if (resultsFullscreen.value) { + resultsFullscreen.value = false; } } }); @@ -1452,6 +1561,7 @@

toggleEvents, portfolioCurrency, chartFullscreen, + resultsFullscreen, }; }, }).mount('#app'); diff --git a/package-lock.json b/package-lock.json index bbcadaa..53ffb93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cf-sim", - "version": "0.6.2", + "version": "0.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cf-sim", - "version": "0.6.2", + "version": "0.6.3", "devDependencies": { "@eslint/js": "^10.0.1", "@playwright/test": "^1.58.2", diff --git a/package.json b/package.json index 49ece16..685929a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cf-sim", - "version": "0.6.2", + "version": "0.6.3", "description": "Visualize your income, expenses, and balance over time", "type": "module", "scripts": { diff --git a/tests/integration/app.spec.js b/tests/integration/app.spec.js index 67ed78a..cb4ebce 100644 --- a/tests/integration/app.spec.js +++ b/tests/integration/app.spec.js @@ -59,7 +59,35 @@ test.describe('Cashflow Simulator App', () => { }); test('CSV export functionality exists', async ({ page }) => { - await expect(page.locator('text=Export CSV')).toBeVisible(); + await expect(page.locator('text=Export CSV').first()).toBeVisible(); + }); + + test('results table has its own Export CSV button that downloads results', async ({ page }) => { + const resultsSection = page.locator('div.bg-white:has(h2:has-text("Results ("))').first(); + const exportButton = resultsSection.getByRole('button', { name: 'Export CSV' }); + await expect(exportButton).toBeVisible(); + + const [download] = await Promise.all([page.waitForEvent('download'), exportButton.click()]); + expect(download.suggestedFilename()).toBe('cashflow-results.csv'); + }); + + test('results table fullscreen toggle opens and closes overlay', async ({ page }) => { + const resultsSection = page.locator('div.bg-white:has(h2:has-text("Results ("))').first(); + await resultsSection.getByRole('button', { name: '⛶' }).click(); + + const overlay = page.locator('.fixed.inset-0.z-50:has-text("Results (")'); + await expect(overlay).toBeVisible(); + await expect(overlay.getByRole('columnheader', { name: 'Date' })).toBeVisible(); + + // Close via ✕ button + await overlay.getByRole('button', { name: '✕' }).click(); + await expect(overlay).toBeHidden(); + + // Reopen and close via Escape + await resultsSection.getByRole('button', { name: '⛶' }).click(); + await expect(overlay).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(overlay).toBeHidden(); }); test('CSV import functionality exists', async ({ page }) => { @@ -191,6 +219,36 @@ test.describe('Cashflow Simulator App', () => { await expect(page.getByText('Ghost', { exact: true })).toHaveCount(0); }); + test('CSV import keeps open-ended events running past a bounded event endDate', async ({ + page, + }) => { + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + await page.locator('input[type="date"]').nth(0).fill('2026-01-01'); // sim start + await page.locator('input[type="date"]').nth(1).fill('2026-03-01'); // sim end + + const csv = [ + 'name,startDate,endDate,frequency,value,currency', + 'Salary,2026-08-28,,monthly,500,USD', + 'Loan,2026-08-12,2027-12-12,monthly,-470,USD', + ].join('\n'); + await page.setInputFiles('input[type="file"]', { + name: 'events.csv', + mimeType: 'text/csv', + buffer: Buffer.from(csv, 'utf8'), + }); + + await expect(page.locator('text=Imported 2 events')).toBeVisible(); + + const table = resultsTable(page); + // The whole horizon must not collapse onto the loan's endDate: + // loan's last payment is on its endDate (inclusive)... + await expect(table.locator('tr', { hasText: '2027-12-12' })).toHaveCount(1); + // ...and the open-ended salary still pays on 2027-12-28, past the loan's end + await expect(table.locator('tr', { hasText: '2027-12-28' })).toHaveCount(1); + }); + test('Fork me on GitHub ribbon links to the repo', async ({ page }) => { const ribbon = page.locator('a.github-fork-ribbon'); await expect(ribbon).toBeVisible();