Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 116 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ <h1 class="text-2xl font-bold">Cashflow Simulator</h1>
</p>
</div>
<div class="flex items-center gap-3 pr-40">
<span class="text-sm text-blue-200">v0.6.2</span>
<span class="text-sm text-blue-200">v0.6.3</span>
<button
@click="toggleTheme"
class="text-white hover:text-blue-200 text-2xl"
Expand All @@ -70,7 +70,10 @@ <h1 class="text-2xl font-bold">Cashflow Simulator</h1>

<!-- Shadow overlay when fullscreen -->
<Transition name="fade">
<div v-if="chartFullscreen" class="fixed inset-0 z-40 bg-black bg-opacity-30"></div>
<div
v-if="chartFullscreen || resultsFullscreen"
class="fixed inset-0 z-40 bg-black bg-opacity-30"
></div>
</Transition>

<main class="container mx-auto p-4 max-w-6xl">
Expand Down Expand Up @@ -481,6 +484,67 @@ <h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
</div>
</Transition>

<!-- Results Table Fullscreen Overlay -->
<Transition name="fade">
<div
v-if="resultsFullscreen"
class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-900 m-4"
>
<div
class="w-full h-full bg-white dark:bg-gray-800 rounded-lg shadow flex flex-col p-4"
>
<div class="flex justify-between items-center mb-3">
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Results ({{ filteredResults.length }} entries)
</h2>
<button
@click="resultsFullscreen = false"
class="text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-100"
>
</button>
</div>
<div class="flex-1 overflow-auto">
<table class="w-full text-sm">
<thead class="bg-gray-100 dark:bg-gray-700 sticky top-0">
<tr>
<th class="px-3 py-2 text-left text-gray-700 dark:text-gray-200">Date</th>
<th class="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
Cashflow
</th>
<th class="px-3 py-2 text-right text-gray-700 dark:text-gray-200">Balance</th>
<th class="px-3 py-2 text-left text-gray-700 dark:text-gray-200">Items</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, i) in filteredResults"
:key="i"
class="border-t border-gray-200 dark:border-gray-600"
>
<td class="px-3 py-2 text-gray-700 dark:text-gray-200">
{{ formatDate(row.date) }}
</td>
<td
class="px-3 py-2 text-right"
:class="row.cashflow >= 0 ? 'text-green-600' : 'text-red-600'"
>
{{ row.cashflow >= 0 ? '+' : '' }}{{ row.cashflow.toFixed(2) }}
</td>
<td class="px-3 py-2 text-right font-medium text-gray-700 dark:text-gray-200">
{{ row.balance.toFixed(2) }}
</td>
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">
{{ row.items.join(', ') || '—' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</Transition>

<!-- Results Table (collapsible) -->
<div class="mt-4">
<!-- Error state -->
Expand Down Expand Up @@ -527,6 +591,20 @@ <h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Results ({{ filteredResults.length }} entries)
</h2>
</div>
<div class="flex gap-2">
<button
@click="resultsFullscreen = !resultsFullscreen"
class="text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 px-3 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
>
{{ resultsFullscreen ? '⊠' : '⛶' }}
</button>
<button
@click="exportResultsCSV"
class="text-sm bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700"
>
Export CSV
</button>
</div>
</div>
<div v-show="!resultsCollapsed" class="overflow-x-auto max-h-64 overflow-y-auto">
<table class="w-full text-sm">
Expand Down Expand Up @@ -652,6 +730,24 @@ <h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
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;
Expand All @@ -678,10 +774,19 @@ <h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
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';
Expand Down Expand Up @@ -782,6 +887,8 @@ <h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">

// Chart fullscreen
const chartFullscreen = ref(false);
// Results table fullscreen
const resultsFullscreen = ref(false);

// --- Auto-run on mount ---
onMounted(() => {
Expand All @@ -801,6 +908,8 @@ <h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
cancelEditEvent();
} else if (chartFullscreen.value) {
chartFullscreen.value = false;
} else if (resultsFullscreen.value) {
resultsFullscreen.value = false;
}
}
});
Expand Down Expand Up @@ -1452,6 +1561,7 @@ <h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
toggleEvents,
portfolioCurrency,
chartFullscreen,
resultsFullscreen,
};
},
}).mount('#app');
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
60 changes: 59 additions & 1 deletion tests/integration/app.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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();
Expand Down
Loading