Implements docs/superpowers/specs/2026-09-02-fast-entry-refinements-design.md.
Three changes shipped together because they share one tab order.
Bulk rows are description -> amount -> note, and nothing else is a tab stop.
The list always ends in exactly one blank row, appended as soon as the last
row is filled, so Tab flows into a new line with no special case and new rows
appear on touch too. The Add-line button is gone; the per-row remove button is
now a hover/focus x with tabIndex={-1}, and Backspace in an empty description
removes the row and lands on the previous row's note.
Purchases no longer demand a day. `date` accepts "2026-09" as well as
"2026-09-14"; entry defaults to the month, with an opt-in "set exact date"
link. No migration: monthOf() already parsed both shapes, verified before
choosing this over splitting the field into month + optional day, which would
have meant a schema migration in the browser against the user's only copy of
their data for an identical outcome.
Notes are an optional field on Purchase, in both entry forms and shown beside
the description in the month lists. Empty notes are omitted rather than stored
as "", so absent stays absent.
Also closes a pre-existing gap the spec surfaced: the JSON importer never
validated purchase.date, so a malformed date passed import and threw later
inside the balance fold. It is now rejected at the boundary, with out-of-range
months and days covered.
A row holding only a note is deliberately NOT blank: it asks for the missing
description and amount rather than being silently discarded.
Keyboard behaviour - tab traversal, Backspace-to-remove, focus after append -
cannot be verified in this environment and needs confirming in a browser.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
103 lines
2.5 KiB
TypeScript
103 lines
2.5 KiB
TypeScript
export const CURRENCIES = ["DKK", "USD", "EUR"] as const;
|
|
export type Currency = (typeof CURRENCIES)[number];
|
|
|
|
export const CURRENCY_DIGITS: Record<Currency, number> = {
|
|
DKK: 2,
|
|
USD: 2,
|
|
EUR: 2,
|
|
};
|
|
|
|
/** A float amount paired with its currency. See CLAUDE.md for rounding rules. */
|
|
export interface Money {
|
|
amount: number;
|
|
currency: Currency;
|
|
}
|
|
|
|
/** "YYYY-MM". Never a Date. */
|
|
export type MonthId = string;
|
|
/**
|
|
* "YYYY-MM-DD", or "YYYY-MM" when a purchase has no specific day — the app is
|
|
* about monthly spending, so a day is optional. `monthOf()` parses both.
|
|
* Never a Date.
|
|
*/
|
|
export type IsoDate = string;
|
|
export type PostId = string;
|
|
export type PurchaseId = string;
|
|
|
|
export type Rule =
|
|
| { kind: "fixed"; amount: Money }
|
|
| { kind: "percentOfIncome"; percent: number };
|
|
|
|
export interface Post {
|
|
id: PostId;
|
|
name: string;
|
|
order: number;
|
|
archived: boolean;
|
|
/** Display currency for this post's own views only. All totals use base. */
|
|
currency: Currency;
|
|
standingRule: Rule;
|
|
}
|
|
|
|
export interface Month {
|
|
id: MonthId;
|
|
/** Always in Settings.baseCurrency. */
|
|
income: Money;
|
|
ruleOverrides: Record<PostId, Rule>;
|
|
}
|
|
|
|
export interface Split {
|
|
postId: PostId;
|
|
/** A percentage, or an amount in the purchase's currency, per Purchase.splitMode. */
|
|
value: number;
|
|
absorbsRemainder: boolean;
|
|
}
|
|
|
|
export interface ScheduleSlice {
|
|
month: MonthId;
|
|
amount: Money;
|
|
}
|
|
|
|
export interface Schedule {
|
|
slices: ScheduleSlice[];
|
|
/** Slices in this month AND every month after it are ignored by the fold. */
|
|
cancelledFromMonth?: MonthId;
|
|
}
|
|
|
|
export interface Purchase {
|
|
id: PurchaseId;
|
|
date: IsoDate;
|
|
description: string;
|
|
/** Optional longer context, alongside the short `description` label. */
|
|
note?: string;
|
|
/** In the purchase's own currency. */
|
|
total: Money;
|
|
splitMode: "percent" | "fixed";
|
|
splits: Split[];
|
|
/** null means the whole total is charged in the month of `date`. */
|
|
schedule: Schedule | null;
|
|
}
|
|
|
|
export interface FxRate {
|
|
currency: Currency;
|
|
/** How many base units one unit of `currency` buys. Base has no row. */
|
|
baseUnitsPerOne: number;
|
|
updatedAt: string;
|
|
source: "manual" | "api";
|
|
}
|
|
|
|
export interface Settings {
|
|
baseCurrency: Currency;
|
|
foldStartMonth: MonthId;
|
|
schemaVersion: number;
|
|
/** Optional: no migration needed, existing datasets leave it undefined. */
|
|
fxApiUrl?: string;
|
|
}
|
|
|
|
export interface Dataset {
|
|
settings: Settings;
|
|
fxRates: FxRate[];
|
|
posts: Post[];
|
|
months: Month[];
|
|
purchases: Purchase[];
|
|
}
|