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
+ |
+ 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)
+
+
+
+
@@ -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();