Skip to content

Commit

Permalink
feat: support custom frequencies for recurring income/expenses (#34)
Browse files Browse the repository at this point in the history
* feat: support adding/editing custom intervals

* feat: display correct yearly amount and intervals

* feat: support copying custom interval income/expenses

* fix: allow numbers greater than 9
  • Loading branch information
dustinwhisman authored Feb 3, 2024
1 parent 1c234b8 commit 1ea6e69
Show file tree
Hide file tree
Showing 9 changed files with 214 additions and 76 deletions.
65 changes: 65 additions & 0 deletions src/components/inputs/FrequencyInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import DateInputs from './DateInputs.svelte';
export let selectedFrequency = '1-month';
export let interval = null;
export let unitOfMeasurement = null;
export let daysOfMonth = [];
export let date;
</script>
Expand Down Expand Up @@ -86,6 +88,17 @@
/>
<label for="frequency__twice-per-month">Twice per Month</label>
</div>
<div>
<input
id="frequency__custom"
type="radio"
name="frequency"
value="custom"
bind:group={selectedFrequency}
class="cmp-form__radio-input"
/>
<label for="frequency__custom">Custom</label>
</div>
</div>
</fieldset>

Expand All @@ -111,6 +124,58 @@
{/each}
</div>
</fieldset>
{:else if selectedFrequency === 'custom'}
<div>
<label for="interval" class="cmp-form__label">Interval</label>
<input
id="interval"
type="text"
name="interval"
value={interval}
class="cmp-form__input cmp-form__input--short"
inputmode="numeric"
pattern="^(0|[1-9]\d*)$"
/>
</div>
<fieldset class="cmp-form__fieldset">
<legend class="cmp-form__legend">Unit of measurement</legend>
<div class="cmp-form__radios">
<div>
<input
id="interval__days"
type="radio"
name="unitOfMeasurement"
value="day"
bind:group={unitOfMeasurement}
class="cmp-form__radio-input"
/>
<label for="interval__days">Days</label>
</div>
<div>
<input
id="interval__weeks"
type="radio"
name="unitOfMeasurement"
value="week"
bind:group={unitOfMeasurement}
class="cmp-form__radio-input"
/>
<label for="interval__weeks">Weeks</label>
</div>
<div>
<input
id="interval__months"
type="radio"
name="unitOfMeasurement"
value="month"
bind:group={unitOfMeasurement}
class="cmp-form__radio-input"
/>
<label for="interval__months">Months</label>
</div>
</div>
</fieldset>
<DateInputs legend="Choose the most recent date when this expense occurred" {date} />
{:else}
<DateInputs legend="Choose the most recent date when this expense occurred" {date} />
{/if}
47 changes: 47 additions & 0 deletions src/lib/copy-recurring.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,53 @@ const copyRecurring =
break;
}
default: {
const gapInDays = Math.floor((currentMonth - originalDate) / 1000 / 3600 / 24);
const [interval, unitOfMeasurement] = entry.frequency.split('-');
let numberOfDays = 0;
let numberOfMonths = 0;
switch (unitOfMeasurement) {
case 'day':
numberOfDays = Number.parseInt(interval, 10);
break;
case 'week':
numberOfDays = Number.parseInt(interval, 10) * 7;
break;
case 'month':
numberOfMonths = Number.parseInt(interval, 10);
break;
default:
break;
}
if (numberOfDays === 0 && numberOfMonths === 0) {
break;
}

let newEntryDate = new Date(numericYear, numericMonth, originalDateDay);
if (numberOfDays > 0) {
const offset = numberOfDays - (gapInDays % numberOfDays);
newEntryDate = new Date(numericYear, numericMonth, 1 + offset);
} else if (numberOfMonths > 0) {
newEntryDate = new Date(
numericYear,
originalDateMonth + numberOfMonths,
originalDateDay
);
}

while (newEntryDate < nextMonth) {
const newEntry = {
...entry,
date: new Date(newEntryDate),
};
delete newEntry.frequency;
delete newEntry.days_of_month;
newEntries.push(newEntry);
if (numberOfDays > 0) {
newEntryDate.setDate(newEntryDate.getDate() + numberOfDays);
} else {
newEntryDate.setDate(newEntryDate.getDate() + numberOfMonths * 31);
}
}
break;
}
}
Expand Down
86 changes: 84 additions & 2 deletions src/lib/format-recurring.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
import { formatDate, formatAmount } from '$lib/format-inputs';

const customAnnualMultiplier = (frequency) => {
const [interval, unitOfMeasurement] = frequency.split('-');
switch (unitOfMeasurement) {
case 'day':
return 365 / Number.parseInt(interval, 10);
case 'week':
return 52 / Number.parseInt(interval, 10);
case 'month':
return 12 / Number.parseInt(interval, 10);
default:
return 1;
}
};

export const annualAmount = ({ frequency, amount }) => {
switch (frequency) {
case '1-month':
Expand All @@ -15,10 +31,15 @@ export const annualAmount = ({ frequency, amount }) => {
case 'twice-per-month':
return amount * 24;
default:
return 0;
return amount * customAnnualMultiplier(frequency);
}
};

const customFrequencyDescription = (frequency) => {
const [interval, unitOfMeasurement] = frequency.split('-');
return `/${interval} ${unitOfMeasurement}${unitOfMeasurement !== '1' ? 's' : ''}`;
};

export const formatFrequency = (frequency) => {
switch (frequency) {
case '1-month':
Expand All @@ -36,6 +57,67 @@ export const formatFrequency = (frequency) => {
case 'twice-per-month':
return ' twice per month';
default:
return '';
return customFrequencyDescription(frequency);
}
};

export const getRecurringEntryFormData = (formData) => {
const amount = formatAmount(formData.get('amount'));

const selectedCategory = formData.get('category');
const newCategory = formData.get('new-category');
const category = newCategory ?? selectedCategory;

const description = formData.get('description') || category;

const now = new Date();
const year = formData.get('year') ?? now.getFullYear();
const month = formData.get('month') ?? now.getMonth() + 1;
const day = formData.get('day') ?? now.getDate();
const date = formatDate(year, month, day);

const frequency = formData.get('frequency');
const interval = formData.get('interval');
const unitOfMeasurement = formData.get('unitOfMeasurement');
const days_of_month = formData.getAll('days-of-month') ?? [];
const active = !!formData.get('active');

return {
amount,
category,
description,
date,
frequency: frequency === 'custom' ? `${interval}-${unitOfMeasurement}` : frequency,
days_of_month,
active,
};
};

export const formatEntry = (entry) => {
let { frequency } = entry;

let interval = null;
let unitOfMeasurement = null;

switch (frequency) {
case '1-month':
case '3-month':
case '6-month':
case '1-year':
case '1-week':
case '2-week':
case 'twice-per-month':
break;
default:
[interval, unitOfMeasurement] = frequency.split('-');
frequency = 'custom';
break;
}

return {
...entry,
frequency,
interval,
unitOfMeasurement,
};
};
21 changes: 3 additions & 18 deletions src/routes/recurring-expenses/expense/+page.server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fail, redirect } from '@sveltejs/kit';
import { formatAmount, formatDate } from '$lib/format-inputs.js';
import { getRecurringEntryFormData } from '$lib/format-recurring.js';
import { gateDynamicPage } from '$lib/gate-dynamic-page.js';

export const load = async ({ locals: { supabase } }) => {
Expand All @@ -19,23 +19,8 @@ export const load = async ({ locals: { supabase } }) => {
export const actions = {
default: async ({ request, locals: { supabase } }) => {
const formData = await request.formData();
const amount = formatAmount(formData.get('amount'));

const selectedCategory = formData.get('category');
const newCategory = formData.get('new-category');
const category = newCategory ?? selectedCategory;

const description = formData.get('description') || category;

const now = new Date();
const year = formData.get('year') ?? now.getFullYear();
const month = formData.get('month') ?? now.getMonth() + 1;
const day = formData.get('day') ?? now.getDate();
const date = formatDate(year, month, day);

const frequency = formData.get('frequency');
const days_of_month = formData.getAll('days-of-month') ?? [];
const active = !!formData.get('active');
const { amount, category, description, date, frequency, days_of_month, active } =
getRecurringEntryFormData(formData);

const {
data: { user },
Expand Down
23 changes: 4 additions & 19 deletions src/routes/recurring-expenses/expense/[id]/+page.server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fail, redirect } from '@sveltejs/kit';
import { formatAmount, formatDate } from '$lib/format-inputs.js';
import { getRecurringEntryFormData, formatEntry } from '$lib/format-recurring.js';
import { gateDynamicPage } from '$lib/gate-dynamic-page.js';

const getCategories = async (supabase) => {
Expand All @@ -21,7 +21,7 @@ const getExpense = async (supabase, id) => {
throw new Error('Could not find the specified expense.');
}

return expense[0];
return formatEntry(expense[0]);
};

export const load = async ({ params: { id }, locals: { supabase } }) => {
Expand All @@ -44,23 +44,8 @@ export const actions = {
const formData = await request.formData();
const id = formData.get('id');

const amount = formatAmount(formData.get('amount'));

const selectedCategory = formData.get('category');
const newCategory = formData.get('new-category');
const category = newCategory ?? selectedCategory;

const description = formData.get('description') || category;

const now = new Date();
const year = formData.get('year') ?? now.getFullYear();
const month = formData.get('month') ?? now.getMonth() + 1;
const day = formData.get('day') ?? now.getDate();
const date = formatDate(year, month, day);

const frequency = formData.get('frequency');
const days_of_month = formData.getAll('days-of-month') ?? [];
const active = !!formData.get('active');
const { amount, category, description, date, frequency, days_of_month, active } =
getRecurringEntryFormData(formData);

const updated_at = new Date();

Expand Down
2 changes: 2 additions & 0 deletions src/routes/recurring-expenses/expense/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
<AmountInput amount={data.expense.amount} />
<FrequencyInput
selectedFrequency={data.expense.frequency}
interval={data.expense.interval}
unitOfMeasurement={data.expense.unitOfMeasurement}
daysOfMonth={data.expense.days_of_month}
date={data.expense.date}
/>
Expand Down
21 changes: 3 additions & 18 deletions src/routes/recurring-income/income/+page.server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fail, redirect } from '@sveltejs/kit';
import { formatAmount, formatDate } from '$lib/format-inputs.js';
import { getRecurringEntryFormData } from '$lib/format-recurring.js';
import { gateDynamicPage } from '$lib/gate-dynamic-page.js';

export const load = async ({ locals: { supabase } }) => {
Expand All @@ -19,23 +19,8 @@ export const load = async ({ locals: { supabase } }) => {
export const actions = {
default: async ({ request, locals: { supabase } }) => {
const formData = await request.formData();
const amount = formatAmount(formData.get('amount'));

const selectedCategory = formData.get('category');
const newCategory = formData.get('new-category');
const category = newCategory ?? selectedCategory;

const description = formData.get('description') || category;

const now = new Date();
const year = formData.get('year') ?? now.getFullYear();
const month = formData.get('month') ?? now.getMonth() + 1;
const day = formData.get('day') ?? now.getDate();
const date = formatDate(year, month, day);

const frequency = formData.get('frequency');
const days_of_month = formData.getAll('days-of-month') ?? [];
const active = !!formData.get('active');
const { amount, category, description, date, frequency, days_of_month, active } =
getRecurringEntryFormData(formData);

const {
data: { user },
Expand Down
Loading

0 comments on commit 1ea6e69

Please sign in to comment.