Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd6859d31a | ||
|
|
1cf560a6ed | ||
|
|
9b418f995c | ||
|
|
df0e19808f | ||
|
|
92db7d4093 | ||
|
|
741e5a49ba | ||
|
|
94fe456a9d | ||
|
|
65d83ddec7 | ||
|
|
54a51752ac | ||
|
|
9c15b81409 | ||
|
|
4a03ab8e1a |
No files matched your search
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM oven/bun:1-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS deps
|
FROM oven/bun:1-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a AS deps
|
||||||
|
|
||||||
ENV TERM=xterm-256color
|
ENV TERM=xterm-256color
|
||||||
ENV COLORTERM=truecolor
|
ENV COLORTERM=truecolor
|
||||||
@@ -19,7 +19,7 @@ RUN bun --bun svelte-kit sync
|
|||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
|
|
||||||
FROM oven/bun:1-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS runtime
|
FROM oven/bun:1-alpine@sha256:4cf82f5686b196504fe119db3fcd9185b191f82ece82c40680e597228ec6070a AS runtime
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
|||||||
+23
-12
@@ -1,28 +1,39 @@
|
|||||||
const KEY = "ffui.budget-map";
|
|
||||||
|
|
||||||
export interface Learned {
|
export interface Learned {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function read(): Record<string, Learned> {
|
function learnedMap(key: string) {
|
||||||
|
const read = (): Record<string, Learned> => {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(localStorage.getItem(KEY) ?? "{}");
|
const value = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||||
return value && typeof value === "object" ? value : {};
|
return value && typeof value === "object" ? value : {};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const learnedBudget = (destinationId: string): Learned | null =>
|
const get = (place: string): Learned | null => read()[place] ?? null;
|
||||||
read()[destinationId] ?? null;
|
|
||||||
|
|
||||||
export function learnBudget(destinationId: string, budget: Learned | null) {
|
const set = (place: string, learned: Learned | null) => {
|
||||||
const map = read();
|
const map = read();
|
||||||
if (budget) {
|
if (learned) {
|
||||||
map[destinationId] = { id: budget.id, name: budget.name };
|
map[place] = { id: learned.id, name: learned.name };
|
||||||
} else {
|
} else {
|
||||||
delete map[destinationId];
|
delete map[place];
|
||||||
}
|
}
|
||||||
localStorage.setItem(KEY, JSON.stringify(map));
|
localStorage.setItem(key, JSON.stringify(map));
|
||||||
|
};
|
||||||
|
|
||||||
|
const remember = (place: string, learned: Learned) => {
|
||||||
|
const before = get(place);
|
||||||
|
set(place, learned);
|
||||||
|
return () => set(place, before);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { get, remember };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const learnedBudgets = learnedMap("ffui.budget-map");
|
||||||
|
|
||||||
|
export const learnedCategories = learnedMap("ffui.category-map");
|
||||||
@@ -75,6 +75,26 @@
|
|||||||
function fill(past: Split, withCounterparty: boolean) {
|
function fill(past: Split, withCounterparty: boolean) {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
const undo: (() => void)[] = [];
|
const undo: (() => void)[] = [];
|
||||||
|
const put = (text: string, still: () => boolean, clear: () => void) => {
|
||||||
|
parts.push(text);
|
||||||
|
undo.push(() => {
|
||||||
|
if (still()) {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const putRef = (field: "bill" | "budget", refId: string, name: string) => {
|
||||||
|
if (withdrawal && !(split[field].id || split[field].name)) {
|
||||||
|
split[field] = { id: refId, name };
|
||||||
|
put(
|
||||||
|
`${field === "bill" ? "subscription" : field} ${name}`,
|
||||||
|
() => split[field].id === refId,
|
||||||
|
() => {
|
||||||
|
split[field] = emptyRef();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
const side = counterparty;
|
const side = counterparty;
|
||||||
if (withCounterparty && side && past.type === form.type) {
|
if (withCounterparty && side && past.type === form.type) {
|
||||||
const pastId = past[`${side}_id`];
|
const pastId = past[`${side}_id`];
|
||||||
@@ -87,33 +107,32 @@
|
|||||||
) {
|
) {
|
||||||
const next = toAccountRef(account);
|
const next = toAccountRef(account);
|
||||||
split[side] = next;
|
split[side] = next;
|
||||||
parts.push(next.name);
|
put(
|
||||||
undo.push(() => {
|
next.name,
|
||||||
if (split[side].id === next.id) {
|
() => split[side].id === next.id,
|
||||||
|
() => {
|
||||||
split[side] = emptyAccount();
|
split[side] = emptyAccount();
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const category = past.category_name ?? "";
|
const category = past.category_name ?? "";
|
||||||
if (category && !split.category.trim()) {
|
if (category && !split.category.trim()) {
|
||||||
split.category = category;
|
split.category = category;
|
||||||
parts.push(`category ${category}`);
|
put(
|
||||||
undo.push(() => {
|
`category ${category}`,
|
||||||
if (split.category === category) {
|
() => split.category === category,
|
||||||
|
() => {
|
||||||
split.category = "";
|
split.category = "";
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
const budget = past.budget_id;
|
if (past.budget_id) {
|
||||||
if (withdrawal && budget && !(split.budget.id || split.budget.name)) {
|
putRef("budget", past.budget_id, past.budget_name ?? "");
|
||||||
split.budget = { id: budget, name: past.budget_name ?? "" };
|
|
||||||
parts.push(`budget ${past.budget_name ?? ""}`);
|
|
||||||
undo.push(() => {
|
|
||||||
if (split.budget.id === budget) {
|
|
||||||
split.budget = emptyRef();
|
|
||||||
}
|
}
|
||||||
});
|
const bill = withCounterparty ? catalog.billById(past.bill_id) : undefined;
|
||||||
|
if (bill) {
|
||||||
|
putRef("bill", bill.id, bill.attributes.name ?? "");
|
||||||
}
|
}
|
||||||
if (parts.length) {
|
if (parts.length) {
|
||||||
filled = {
|
filled = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
Page,
|
Page,
|
||||||
TransactionGroup,
|
TransactionGroup,
|
||||||
} from "#lib/api/types.js";
|
} from "#lib/api/types.js";
|
||||||
import { learnedBudget } from "#lib/budgets/map.js";
|
import { learnedBudgets, learnedCategories } from "#lib/budgets/map.js";
|
||||||
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
import { formatMoney } from "#lib/format.js";
|
import { formatMoney } from "#lib/format.js";
|
||||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
@@ -66,6 +66,12 @@ export class Catalog {
|
|||||||
return (this.accounts.data ?? []).find((item) => item.id === id);
|
return (this.accounts.data ?? []).find((item) => item.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
billById(id: string | null | undefined) {
|
||||||
|
return (this.bills.data ?? []).find(
|
||||||
|
(item) => item.id === id && item.attributes.active
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
accountOptions(kinds: string[], query: string): Option[] {
|
accountOptions(kinds: string[], query: string): Option[] {
|
||||||
const items = rank(
|
const items = rank(
|
||||||
this.accountsOf(kinds),
|
this.accountsOf(kinds),
|
||||||
@@ -235,23 +241,41 @@ export class Catalog {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
this.history.set(key, found);
|
this.history.set(key, found);
|
||||||
}
|
}
|
||||||
const learned = learnedBudget(accountId)?.id;
|
const learnedBudget = learnedBudgets.get(accountId)?.id;
|
||||||
const budget = this.budgets.data?.find(
|
const budget =
|
||||||
(item) => item.id === learned && item.attributes.active
|
type === "withdrawal" && side === "destination"
|
||||||
);
|
? this.budgets.data?.find(
|
||||||
if (!budget || type !== "withdrawal" || side !== "destination") {
|
(item) => item.id === learnedBudget && item.attributes.active
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const ownSide = type === "deposit" ? "source" : "destination";
|
||||||
|
const learnedCategory = learnedCategories.get(`${type}:${accountId}`)?.id;
|
||||||
|
const category =
|
||||||
|
side === ownSide
|
||||||
|
? this.categories.data?.find((item) => item.id === learnedCategory)
|
||||||
|
: undefined;
|
||||||
|
if (!(budget || category)) {
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
return found.then(
|
return found.then((past) => {
|
||||||
(past) =>
|
const split = past?.split;
|
||||||
past && {
|
if (!split) {
|
||||||
|
return past;
|
||||||
|
}
|
||||||
|
return {
|
||||||
split: {
|
split: {
|
||||||
...past.split,
|
...split,
|
||||||
|
...(budget && {
|
||||||
budget_id: budget.id,
|
budget_id: budget.id,
|
||||||
budget_name: budget.attributes.name,
|
budget_name: budget.attributes.name,
|
||||||
|
}),
|
||||||
|
...(category && {
|
||||||
|
category_id: category.id,
|
||||||
|
category_name: category.attributes.name,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fromJournal(journalId: string): Promise<Recent | null> {
|
async fromJournal(journalId: string): Promise<Recent | null> {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||||
import { Popover } from "bits-ui";
|
import { Popover } from "bits-ui";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { ui } from "#lib/ui.svelte.js";
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
let start = $state(period.start);
|
let start = $state(period.start);
|
||||||
let end = $state(period.end);
|
let end = $state(period.end);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (ui.periodOpen) {
|
||||||
({ start, end } = period);
|
({ start, end } = period);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (start && end && start <= end) {
|
if (start && end && start <= end) {
|
||||||
period.set({ end, start });
|
period.set({ end, start });
|
||||||
open = false;
|
ui.periodOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
>
|
>
|
||||||
<ChevronLeft class="size-4" />
|
<ChevronLeft class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<Popover.Root bind:open>
|
<Popover.Root bind:open={ui.periodOpen}>
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
class="flex h-8 items-center gap-2 px-2 font-medium text-[13px] hover:bg-black/10"
|
class="flex h-8 items-center gap-2 px-2 font-medium text-[13px] hover:bg-black/10"
|
||||||
title="Choose period"
|
title="Choose period"
|
||||||
@@ -59,11 +59,16 @@
|
|||||||
: ""}"
|
: ""}"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
period.set(preset.range);
|
period.set(preset.range);
|
||||||
open = false;
|
ui.periodOpen = false;
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
|
{#if preset.key || preset.hint}
|
||||||
|
<span class="kbd"
|
||||||
|
>{preset.key ? `p ${preset.key}` : preset.hint}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -76,6 +81,7 @@
|
|||||||
Start
|
Start
|
||||||
<input
|
<input
|
||||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||||
|
id="period-start"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={start}
|
bind:value={start}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,6 +36,18 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function setPreset(key: string | undefined) {
|
||||||
|
const preset = period.presets().find((item) => item.key === key);
|
||||||
|
if (preset) {
|
||||||
|
period.set(preset.range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCustomPeriod() {
|
||||||
|
ui.periodOpen = true;
|
||||||
|
setTimeout(() => document.getElementById("period-start")?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
function openOld(sticky = false) {
|
function openOld(sticky = false) {
|
||||||
location.assign(oldUiUrl(page.url.href, sticky));
|
location.assign(oldUiUrl(page.url.href, sticky));
|
||||||
}
|
}
|
||||||
@@ -148,6 +160,27 @@
|
|||||||
label: "Current period",
|
label: "Current period",
|
||||||
run: () => period.reset(),
|
run: () => period.reset(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: "Period",
|
||||||
|
keys: "p p",
|
||||||
|
label: "Choose a period",
|
||||||
|
run: () => (ui.periodOpen = true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Period",
|
||||||
|
keys: "p c",
|
||||||
|
label: "Custom period: type the dates",
|
||||||
|
run: openCustomPeriod,
|
||||||
|
},
|
||||||
|
...period
|
||||||
|
.presets()
|
||||||
|
.filter((preset) => preset.key)
|
||||||
|
.map((preset) => ({
|
||||||
|
group: "Period",
|
||||||
|
keys: `p ${preset.key}`,
|
||||||
|
label: `Period: ${preset.label}`,
|
||||||
|
run: () => setPreset(preset.key),
|
||||||
|
})),
|
||||||
{
|
{
|
||||||
group: "Global",
|
group: "Global",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
onclick={() => byCurrency || onmode()}
|
onclick={() => byCurrency || onmode()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
By currency<span class="kbd max-sm:hidden">p</span>
|
By currency<span class="kbd max-sm:hidden">v</span>
|
||||||
</button>
|
</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+55
-5
@@ -118,12 +118,50 @@ export function periodOf(value: string, view: ViewRange): Range {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UNITS: ViewRange[] = ["1M", "3M", "6M", "1Y"];
|
||||||
|
|
||||||
|
function unitOf(range: Range, view: ViewRange): ViewRange | null {
|
||||||
|
const units = isDynamic(view) ? UNITS : [view, ...UNITS];
|
||||||
|
return (
|
||||||
|
units.find((unit) => {
|
||||||
|
const aligned = periodOf(range.start, unit);
|
||||||
|
return aligned.start === range.start && range.end <= aligned.end;
|
||||||
|
}) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wholeMonths(range: Range): number {
|
||||||
|
const after = addDays(range.end, 1);
|
||||||
|
if (!(range.start.endsWith("-01") && after.endsWith("-01"))) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const months = (value: string) =>
|
||||||
|
Number(value.slice(0, 4)) * 12 + Number(value.slice(5, 7));
|
||||||
|
return months(after) - months(range.start);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarShift(
|
||||||
|
range: Range,
|
||||||
|
view: ViewRange,
|
||||||
|
dir: 1 | -1
|
||||||
|
): Range | null {
|
||||||
|
const unit = unitOf(range, view);
|
||||||
|
if (unit) {
|
||||||
|
const anchor = dir === 1 ? periodOf(range.start, unit).end : range.start;
|
||||||
|
return periodOf(addDays(anchor, dir), unit);
|
||||||
|
}
|
||||||
|
const months = wholeMonths(range);
|
||||||
|
if (months === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const start = addMonths(range.start, dir * months);
|
||||||
|
return { end: addDays(addMonths(start, months), -1), start };
|
||||||
|
}
|
||||||
|
|
||||||
export function shiftPeriod(range: Range, view: ViewRange, dir: 1 | -1): Range {
|
export function shiftPeriod(range: Range, view: ViewRange, dir: 1 | -1): Range {
|
||||||
const fixed = isDynamic(view) ? "1M" : view;
|
const calendar = calendarShift(range, view, dir);
|
||||||
const aligned = periodOf(range.start, fixed);
|
if (calendar) {
|
||||||
if (aligned.start === range.start && aligned.end === range.end) {
|
return calendar;
|
||||||
const anchor = dir === 1 ? addDays(range.end, 1) : addDays(range.start, -1);
|
|
||||||
return periodOf(anchor, fixed);
|
|
||||||
}
|
}
|
||||||
const days = daysBetween(range.start, range.end) + 1;
|
const days = daysBetween(range.start, range.end) + 1;
|
||||||
return {
|
return {
|
||||||
@@ -132,6 +170,18 @@ export function shiftPeriod(range: Range, view: ViewRange, dir: 1 | -1): Range {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stepPeriod(range: Range, view: ViewRange, dir: 1 | -1): Range {
|
||||||
|
const calendar = calendarShift(range, view, dir);
|
||||||
|
if (calendar) {
|
||||||
|
return calendar;
|
||||||
|
}
|
||||||
|
const unit = isDynamic(view) ? "1M" : view;
|
||||||
|
const edge = dir === 1 ? range.end : range.start;
|
||||||
|
const own = periodOf(edge, unit);
|
||||||
|
const reached = (dir === 1 ? own.end : own.start) === edge;
|
||||||
|
return reached ? periodOf(addDays(edge, dir), unit) : own;
|
||||||
|
}
|
||||||
|
|
||||||
export function daysBetween(a: string, b: string): number {
|
export function daysBetween(a: string, b: string): number {
|
||||||
return Math.round((parse(b).getTime() - parse(a).getTime()) / 86_400_000);
|
return Math.round((parse(b).getTime() - parse(a).getTime()) / 86_400_000);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-11
@@ -5,7 +5,7 @@ import {
|
|||||||
isDynamic,
|
isDynamic,
|
||||||
periodOf,
|
periodOf,
|
||||||
type Range,
|
type Range,
|
||||||
shiftPeriod,
|
stepPeriod,
|
||||||
today,
|
today,
|
||||||
type ViewRange,
|
type ViewRange,
|
||||||
} from "#lib/dates.js";
|
} from "#lib/dates.js";
|
||||||
@@ -15,13 +15,15 @@ const STORAGE_KEY = "ffui.period";
|
|||||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
export interface Preset {
|
export interface Preset {
|
||||||
|
hint?: string;
|
||||||
|
key?: string;
|
||||||
label: string;
|
label: string;
|
||||||
range: Range;
|
range: Range;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stored(): Range | null {
|
function stored(): Range | null {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null");
|
const value = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "null");
|
||||||
return ISO_DATE.test(value?.start) && ISO_DATE.test(value?.end)
|
return ISO_DATE.test(value?.start) && ISO_DATE.test(value?.end)
|
||||||
? value
|
? value
|
||||||
: null;
|
: null;
|
||||||
@@ -69,7 +71,7 @@ class Period {
|
|||||||
const range = rangeFromUrl(url) ?? stored() ?? periodOf(today(), view);
|
const range = rangeFromUrl(url) ?? stored() ?? periodOf(today(), view);
|
||||||
this.start = range.start;
|
this.start = range.start;
|
||||||
this.end = range.end;
|
this.end = range.end;
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(range));
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(range));
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +97,11 @@ class Period {
|
|||||||
}
|
}
|
||||||
this.start = range.start;
|
this.start = range.start;
|
||||||
this.end = range.end;
|
this.end = range.end;
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(range));
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(range));
|
||||||
}
|
}
|
||||||
|
|
||||||
shift(dir: 1 | -1) {
|
shift(dir: 1 | -1) {
|
||||||
this.set(shiftPeriod(this.range, this.view, dir));
|
this.set(stepPeriod(this.range, this.view, dir));
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
@@ -111,33 +113,46 @@ class Period {
|
|||||||
const { view } = this;
|
const { view } = this;
|
||||||
const presets: Preset[] = [];
|
const presets: Preset[] = [];
|
||||||
if (!isDynamic(view)) {
|
if (!isDynamic(view)) {
|
||||||
const prev = shiftPeriod(this.range, view, -1);
|
const prev = stepPeriod(this.range, view, -1);
|
||||||
const next = shiftPeriod(this.range, view, 1);
|
const next = stepPeriod(this.range, view, 1);
|
||||||
presets.push({ label: periodLabel(prev, view), range: prev });
|
presets.push({ hint: "[", label: periodLabel(prev, view), range: prev });
|
||||||
presets.push({ label: periodLabel(next, view), range: next });
|
presets.push({ hint: "]", label: periodLabel(next, view), range: next });
|
||||||
}
|
}
|
||||||
const current = periodOf(now, view);
|
const current = periodOf(now, view);
|
||||||
presets.push({
|
presets.push({
|
||||||
|
key: "t",
|
||||||
label: `Today (${periodLabel(current, view)})`,
|
label: `Today (${periodLabel(current, view)})`,
|
||||||
range: current,
|
range: current,
|
||||||
});
|
});
|
||||||
presets.push({
|
presets.push({
|
||||||
|
key: "7",
|
||||||
label: "Last seven days",
|
label: "Last seven days",
|
||||||
range: { end: now, start: addDays(now, -7) },
|
range: { end: now, start: addDays(now, -7) },
|
||||||
});
|
});
|
||||||
presets.push({
|
presets.push({
|
||||||
|
key: "3",
|
||||||
label: "Last thirty days",
|
label: "Last thirty days",
|
||||||
range: { end: now, start: addDays(now, -30) },
|
range: { end: now, start: addDays(now, -30) },
|
||||||
});
|
});
|
||||||
presets.push({ label: "Month to date", range: periodOf(now, "MTD") });
|
presets.push({
|
||||||
presets.push({ label: "Year to date", range: periodOf(now, "YTD") });
|
key: "m",
|
||||||
|
label: "Month to date",
|
||||||
|
range: periodOf(now, "MTD"),
|
||||||
|
});
|
||||||
|
presets.push({
|
||||||
|
key: "y",
|
||||||
|
label: "Year to date",
|
||||||
|
range: periodOf(now, "YTD"),
|
||||||
|
});
|
||||||
const year = Number(now.slice(0, 4)) - 1;
|
const year = Number(now.slice(0, 4)) - 1;
|
||||||
presets.push({
|
presets.push({
|
||||||
|
key: "l",
|
||||||
label: `Previous year (${year})`,
|
label: `Previous year (${year})`,
|
||||||
range: { end: `${year}-12-31`, start: `${year}-01-01` },
|
range: { end: `${year}-12-31`, start: `${year}-01-01` },
|
||||||
});
|
});
|
||||||
if (this.first) {
|
if (this.first) {
|
||||||
presets.push({
|
presets.push({
|
||||||
|
key: "a",
|
||||||
label: "Everything",
|
label: "Everything",
|
||||||
range: { end: now, start: this.first },
|
range: { end: now, start: this.first },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ export const runwayQueries = {
|
|||||||
bills: (start: string, end: string) => ({
|
bills: (start: string, end: string) => ({
|
||||||
queryFn: () => all<Bill>("/v1/bills", { end, start }),
|
queryFn: () => all<Bill>("/v1/bills", { end, start }),
|
||||||
queryKey: ["subscriptions", "runway", start, end],
|
queryKey: ["subscriptions", "runway", start, end],
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
}),
|
}),
|
||||||
paid: () => ({
|
paid: () => ({
|
||||||
queryFn: async () => toPayments(await search("has_any_bill:true")),
|
queryFn: async () => toPayments(await search("has_any_bill:true")),
|
||||||
queryKey: ["subscriptions", "payments"],
|
queryKey: ["subscriptions", "payments"],
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
}),
|
}),
|
||||||
unlinked: (day: string) => {
|
unlinked: (day: string) => {
|
||||||
const after = addDays(day, -UNLINKED_DAYS);
|
const after = addDays(day, -UNLINKED_DAYS);
|
||||||
|
|||||||
@@ -208,11 +208,7 @@ function occurrences(bill: Bill, day: string) {
|
|||||||
return (bill.pay_dates ?? [])
|
return (bill.pay_dates ?? [])
|
||||||
.map((raw) => raw.slice(0, 10))
|
.map((raw) => raw.slice(0, 10))
|
||||||
.filter((date) => !end || date <= end)
|
.filter((date) => !end || date <= end)
|
||||||
.filter(
|
.filter((date) => !paid.some((p) => Math.abs(daysBetween(p, date)) < early))
|
||||||
(date) =>
|
|
||||||
date < day ||
|
|
||||||
!paid.some((p) => p <= date && daysBetween(p, date) < early)
|
|
||||||
)
|
|
||||||
.map((date) => ({ date: date < day ? day : date, overdue: date < day }));
|
.map((date) => ({ date: date < day ? day : date, overdue: date < day }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class Ui {
|
|||||||
palette = $state(false);
|
palette = $state(false);
|
||||||
paletteQuery = $state("");
|
paletteQuery = $state("");
|
||||||
quickEntry = $state(false);
|
quickEntry = $state(false);
|
||||||
|
periodOpen = $state(false);
|
||||||
mobileNav = $state(false);
|
mobileNav = $state(false);
|
||||||
collapsed = $state(stored(SIDEBAR_KEY));
|
collapsed = $state(stored(SIDEBAR_KEY));
|
||||||
notes = $state(stored(NOTES_KEY));
|
notes = $state(stored(NOTES_KEY));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { invalidate } from "#lib/api/lists.js";
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
import type { Budget, One } from "#lib/api/types.js";
|
import type { Budget, One } from "#lib/api/types.js";
|
||||||
import { learnBudget, learnedBudget } from "#lib/budgets/map.js";
|
import { learnedBudgets } from "#lib/budgets/map.js";
|
||||||
import {
|
import {
|
||||||
type Place,
|
type Place,
|
||||||
placesOf,
|
placesOf,
|
||||||
@@ -269,12 +269,6 @@
|
|||||||
return { id: made.data.id, name: made.data.attributes.name };
|
return { id: made.data.id, name: made.data.attributes.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
function remember(place: Place, option: { id: string; name: string }) {
|
|
||||||
const before = learnedBudget(place.accountId);
|
|
||||||
learnBudget(place.accountId, option);
|
|
||||||
return () => learnBudget(place.accountId, before);
|
|
||||||
}
|
|
||||||
|
|
||||||
function done(index: number, move: Move) {
|
function done(index: number, move: Move) {
|
||||||
adding = null;
|
adding = null;
|
||||||
const next = rows[index + move];
|
const next = rows[index + move];
|
||||||
@@ -739,12 +733,12 @@
|
|||||||
? "Every expense has a budget."
|
? "Every expense has a budget."
|
||||||
: `Every expense in ${period.label} has a budget.`}
|
: `Every expense in ${period.label} has a budget.`}
|
||||||
everything={scope === "all"}
|
everything={scope === "all"}
|
||||||
hint={(place) => learnedBudget(place.accountId)?.name ?? null}
|
hint={(place) => learnedBudgets.get(place.accountId)?.name ?? null}
|
||||||
ignoreKey="ffui.budget-ignored"
|
ignoreKey="ffui.budget-ignored"
|
||||||
loading={triage.isPending}
|
loading={triage.isPending}
|
||||||
noun="budget"
|
noun="budget"
|
||||||
onactivate={() => (triaging = true)}
|
onactivate={() => (triaging = true)}
|
||||||
onassign={remember}
|
onassign={(place, option) => learnedBudgets.remember(place.accountId, option)}
|
||||||
onchange={() => refreshExcept(triageKey)}
|
onchange={() => refreshExcept(triageKey)}
|
||||||
onleave={() => (triaging = false)}
|
onleave={() => (triaging = false)}
|
||||||
onswitch={() => setParam("scope", scope === "all" ? "" : "all")}
|
onswitch={() => setParam("scope", scope === "all" ? "" : "all")}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { invalidate } from "#lib/api/lists.js";
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
import type { Category, One } from "#lib/api/types.js";
|
import type { Category, One } from "#lib/api/types.js";
|
||||||
|
import { learnedCategories } from "#lib/budgets/map.js";
|
||||||
import {
|
import {
|
||||||
placesOf,
|
placesOf,
|
||||||
refreshExcept,
|
refreshExcept,
|
||||||
@@ -414,10 +415,12 @@
|
|||||||
? "Every transaction has a category."
|
? "Every transaction has a category."
|
||||||
: `Every transaction in ${period.label} has a category.`}
|
: `Every transaction in ${period.label} has a category.`}
|
||||||
{everything}
|
{everything}
|
||||||
|
hint={(place) => learnedCategories.get(place.key)?.name ?? null}
|
||||||
ignoreKey="ffui.category-ignored"
|
ignoreKey="ffui.category-ignored"
|
||||||
loading={triage.isPending}
|
loading={triage.isPending}
|
||||||
noun="category"
|
noun="category"
|
||||||
onactivate={() => (triaging = true)}
|
onactivate={() => (triaging = true)}
|
||||||
|
onassign={(place, option) => learnedCategories.remember(place.key, option)}
|
||||||
onchange={() => refreshExcept(triageKey)}
|
onchange={() => refreshExcept(triageKey)}
|
||||||
onleave={() => (triaging = false)}
|
onleave={() => (triaging = false)}
|
||||||
onswitch={() => setParam("scope", everything ? "" : "all")}
|
onswitch={() => setParam("scope", everything ? "" : "all")}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: "Currencies",
|
group: "Currencies",
|
||||||
keys: "p",
|
keys: "P",
|
||||||
label: "Make primary currency",
|
label: "Make primary currency",
|
||||||
run: () => nav.current && makePrimary(nav.current),
|
run: () => nav.current && makePrimary(nav.current),
|
||||||
},
|
},
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onSelect={() => makePrimary(item)}>
|
<ContextMenuItem onSelect={() => makePrimary(item)}>
|
||||||
Make primary
|
Make primary
|
||||||
<span class="ml-auto kbd">p</span>
|
<span class="ml-auto kbd">P</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<MenuDelete {arm} id={item.id} run={() => remove(item)} />
|
<MenuDelete {arm} id={item.id} run={() => remove(item)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -296,7 +296,7 @@
|
|||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
group: "Runway",
|
group: "Runway",
|
||||||
keys: "p",
|
keys: "v",
|
||||||
label: perCurrency ? "Group by card" : "Group by currency",
|
label: perCurrency ? "Group by card" : "Group by currency",
|
||||||
run: toggleMode,
|
run: toggleMode,
|
||||||
},
|
},
|
||||||
@@ -491,7 +491,9 @@
|
|||||||
{@const ends = bill.end_date?.slice(0, 10)}
|
{@const ends = bill.end_date?.slice(0, 10)}
|
||||||
{@const last = lastOf(item)}
|
{@const last = lastOf(item)}
|
||||||
{@const who = payer(item)}
|
{@const who = payer(item)}
|
||||||
{@const stale = staleSince(bill, last, day)}
|
{@const stale = paid.isSuccess
|
||||||
|
? staleSince(bill, last, day)
|
||||||
|
: null}
|
||||||
{@const extra = found.get(item.id) ?? []}
|
{@const extra = found.get(item.id) ?? []}
|
||||||
{@const armedHere = arm.key?.endsWith(`:${item.id}`)}
|
{@const armedHere = arm.key?.endsWith(`:${item.id}`)}
|
||||||
<tr
|
<tr
|
||||||
@@ -567,7 +569,7 @@
|
|||||||
billIds={manualCards.picking.ids}
|
billIds={manualCards.picking.ids}
|
||||||
cards={cardList}
|
cards={cardList}
|
||||||
/>
|
/>
|
||||||
{:else if bill.active}
|
{:else if bill.active && paid.isSuccess}
|
||||||
{#if who.card}
|
{#if who.card}
|
||||||
<span class="text-cladd-fg-soft"
|
<span class="text-cladd-fg-soft"
|
||||||
>{who.card.name}</span
|
>{who.card.name}</span
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ x-firefly-env: &firefly-env
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
v1:
|
v1:
|
||||||
image: fireflyiii/core:version-6.6.6
|
image: fireflyiii/core:version-6.6.6@sha256:ae69fdd95cdef9038cd7a460a5aec731f14813973e4f096511d5a4ea9ff0e972
|
||||||
environment:
|
environment:
|
||||||
<<: *firefly-env
|
<<: *firefly-env
|
||||||
APP_KEY: ffuistandv1ffuistandv1ffuistand1
|
APP_KEY: ffuistandv1ffuistandv1ffuistand1
|
||||||
@@ -26,7 +26,7 @@ services:
|
|||||||
- "127.0.0.1:${FFUI_V1_PORT:-30072}:8080"
|
- "127.0.0.1:${FFUI_V1_PORT:-30072}:8080"
|
||||||
|
|
||||||
v3:
|
v3:
|
||||||
image: fireflyiii/core:latest
|
image: fireflyiii/core:latest@sha256:d51ab6a3a7b91a5c892441578e0da7f2935f84f36bd1151fc3833acc6e060871
|
||||||
environment:
|
environment:
|
||||||
<<: *firefly-env
|
<<: *firefly-env
|
||||||
APP_KEY: ffuistandv3ffuistandv3ffuistand3
|
APP_KEY: ffuistandv3ffuistandv3ffuistand3
|
||||||
@@ -52,7 +52,7 @@ services:
|
|||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
profiles: [prodlike]
|
profiles: [prodlike]
|
||||||
image: caddy:2-alpine@sha256:d8542f48d34a9cf4e4c11a478865229840e87e4c96ea3f439101f31a5d35f75f
|
image: caddy:2-alpine@sha256:eb4d35b7452d1ab6171fbdbe8d9275b2301dad6dad92115d91e19fb9b24d510f
|
||||||
environment:
|
environment:
|
||||||
FIREFLY_DOMAIN: ":8080"
|
FIREFLY_DOMAIN: ":8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in new issue
Block a user