+
+
+
+
+
+
+ Results ({{ filteredResults.length }} entries)
+
+
+
+
+
+
+
+ Date
+
+ Cashflow
+
+ Balance
+ Items
+
+
+
+
+
+ {{ 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)
+
+
+
+
@@ -782,6 +860,8 @@
// Chart fullscreen
const chartFullscreen = ref(false);
+ // Results table fullscreen
+ const resultsFullscreen = ref(false);
// --- Auto-run on mount ---
onMounted(() => {
@@ -801,6 +881,8 @@
cancelEditEvent();
} else if (chartFullscreen.value) {
chartFullscreen.value = false;
+ } else if (resultsFullscreen.value) {
+ resultsFullscreen.value = false;
}
}
});
@@ -1452,6 +1534,7 @@
toggleEvents,
portfolioCurrency,
chartFullscreen,
+ resultsFullscreen,
};
},
}).mount('#app');
diff --git a/tests/integration/app.spec.js b/tests/integration/app.spec.js
index 67ed78a..59a43af 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 }) => {
From 4e5fb348ef912d5e30954a6c0c90c59056cd12e5 Mon Sep 17 00:00:00 2001
From: Thiago Macedo <1750447+macedot@users.noreply.github.com>
Date: Thu, 3 Sep 2026 13:59:47 +0200
Subject: [PATCH 2/3] fix: extend sim horizon past bounded endDate so
open-ended events continue
Importing events where a bounded event (e.g. a loan) ended later than the
default period set the simulation end exactly at that endDate, silently
truncating open-ended events: an open-ended salary lost its payment in the
month after the loan's last payment, and the whole result stopped there.
When open-ended events exist, the auto-expanded period now extends one
month past the last bounded endDate (clamped to month length) so their
recurrence stays visible beyond it. Bounded-only and open-ended-only
imports behave exactly as before.
---
index.html | 35 +++++++++++++++++++++++++++++++----
tests/integration/app.spec.js | 30 ++++++++++++++++++++++++++++++
2 files changed, 61 insertions(+), 4 deletions(-)
diff --git a/index.html b/index.html
index ffc2c27..7713191 100644
--- a/index.html
+++ b/index.html
@@ -730,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;
@@ -756,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';
diff --git a/tests/integration/app.spec.js b/tests/integration/app.spec.js
index 59a43af..cb4ebce 100644
--- a/tests/integration/app.spec.js
+++ b/tests/integration/app.spec.js
@@ -219,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();
From f4a9bc90f5b9ebf949dc64210331268cc847ab2b Mon Sep 17 00:00:00 2001
From: Thiago Macedo <1750447+macedot@users.noreply.github.com>
Date: Thu, 3 Sep 2026 14:00:01 +0200
Subject: [PATCH 3/3] chore: bump version to 0.6.3
---
index.html | 2 +-
package-lock.json | 4 ++--
package.json | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/index.html b/index.html
index 7713191..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 + | +Balance | +Items | +
|---|---|---|---|
| + {{ formatDate(row.date) }} + | ++ {{ row.cashflow >= 0 ? '+' : '' }}{{ row.cashflow.toFixed(2) }} + | ++ {{ row.balance.toFixed(2) }} + | ++ {{ row.items.join(', ') || '—' }} + | +