Compare commits

...
Author SHA1 Message Date
renovate Bot f2e7444d88 chore(frontend): update template to v0.8.0, conflicts left 2026-10-20 05:00:35 +00:00
hh 65d83ddec7 fix(period): [ and ] after month or year to date step by whole months or years
Month to date went back to 13-30 Sept because a partial range was moved
by its length; a range that starts a month, quarter, half or year now
steps by that unit.
2026-10-18 17:26:58 +00:00
hh 54a51752ac fix(subscriptions): card and never-paid wait for the payments instead of guessing while they load 2026-10-18 17:19:43 +00:00
hh 9c15b81409 fix(subscriptions): a payment made around a past due date counts as paid, runway refetches on focus
A bill paid a day before its due date showed as overdue in the runway
because past due dates were never matched against payments; now any
payment within a third of the cycle around the date settles it. The
runway's bills and payments refetch when the tab gets focus, so entries
the bot made meanwhile show up.
2026-10-18 17:02:12 +00:00
hh 4a03ab8e1a feat(period): p and a letter picks a period from any page
p t current period, p m month to date, p 7 and p 3 the last seven and
thirty days, p y year to date, p l last year, p a everything, p c
custom dates, p p opens the period menu, which now shows each key. The
same presets are in the palette. Currencies make primary moves to P and
the subscriptions card/currency toggle to v to free p.
2026-10-17 16:13:29 +00:00
13 changed files with 100 additions and 31 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
_commit: v0.7.0 _commit: v0.8.0
_src_path: https://git.kotikot.com/templates/frontend-svelte.git _src_path: https://git.kotikot.com/templates/frontend-svelte.git
install_deps: true install_deps: true
layout: root layout: root
+4 -5
View File
@@ -7,20 +7,19 @@
"!.svelte-kit", "!.svelte-kit",
"!build", "!build",
"!.mcp.json", "!.mcp.json",
<<<<<<< before updating
"!.impeccable", "!.impeccable",
"!src/lib/components/ui", "!src/lib/components/ui",
"!src/routes/claddcn.css", "!src/routes/claddcn.css",
"!!src/lib/api/schema.d.ts", "!!src/lib/api/schema.d.ts",
"!api" "!api"
=======
"!src/lib/components/ui"
>>>>>>> after updating
] ]
}, },
"javascript": { "globals": ["Bun"] }, "javascript": { "globals": ["Bun"] },
"overrides": [ "overrides": [
{
"includes": ["src/lib/components/ui/**"],
"linter": { "enabled": false },
"assist": { "enabled": false }
},
{ {
"includes": ["src/lib/utils.ts"], "includes": ["src/lib/utils.ts"],
"linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } }
+4
View File
@@ -40,9 +40,13 @@
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"d3-shape": "^3.2.0", "d3-shape": "^3.2.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
<<<<<<< before updating
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"shadcn-svelte": "^1.3.0", "shadcn-svelte": "^1.3.0",
=======
"shadcn-svelte": "^1.7.0",
>>>>>>> after updating
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"svelte-sonner": "^1.1.0", "svelte-sonner": "^1.1.0",
+11 -5
View File
@@ -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}
> >
+33
View File
@@ -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>
+16 -5
View File
@@ -118,12 +118,23 @@ 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
);
}
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 unit = unitOf(range, view);
const aligned = periodOf(range.start, fixed); if (unit) {
if (aligned.start === range.start && aligned.end === range.end) { const anchor = dir === 1 ? periodOf(range.start, unit).end : range.start;
const anchor = dir === 1 ? addDays(range.end, 1) : addDays(range.start, -1); return periodOf(addDays(anchor, dir), unit);
return periodOf(anchor, fixed);
} }
const days = daysBetween(range.start, range.end) + 1; const days = daysBetween(range.start, range.end) + 1;
return { return {
+19 -4
View File
@@ -15,6 +15,8 @@ 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;
} }
@@ -113,31 +115,44 @@ class Period {
if (!isDynamic(view)) { if (!isDynamic(view)) {
const prev = shiftPeriod(this.range, view, -1); const prev = shiftPeriod(this.range, view, -1);
const next = shiftPeriod(this.range, view, 1); const next = shiftPeriod(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 },
}); });
+2
View File
@@ -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);
+1 -5
View File
@@ -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 }));
} }
+1
View File
@@ -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));
+2 -2
View File
@@ -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}
+5 -3
View File
@@ -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