Skip to content
Draft
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
12 changes: 12 additions & 0 deletions apps/ottabase-template-app-tanstack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,15 @@ Ottabase now includes a dynamic marketing pages system with OttaORM CRUD + drag-
- `/admin/pages` — list/create/duplicate/delete marketing pages
- `/admin/pages/$pageId` — block builder with drag-and-drop reordering and inline editor
- Public preview route in TanStack app: `/pages/$slug`

Notes:

- The admin pages list and builder consume OttaORM `useList()` hooks as arrays, while still tolerating legacy CRUD
payload wrappers (`{ data: [...] }` and `{ data: { data: [...] } }`) for list/detail/mutation payloads.
- The public marketing renderer (`/pages/$slug`) now mirrors the Next.js homepage variant family for `navbar`, `hero`,
`features`, `cta`, `footer`, and `about` slots.
- Duplicate and delete operations in the admin pages list/builder now use shadcn `AlertDialog` confirmations.
- Admin page builder now supports image selection from `@ottabase/medialibrary` for section media and feature images.
- Admin page builder includes a live preview panel with desktop/tablet/mobile viewport switching.
- Admin block editor includes an AI Copy Assistant powered by `@ottabase/cf-ai` with per-field generation plus bulk
Generate All and rewrite/shorten/expand actions for title/subtitle/body copy.
1 change: 1 addition & 0 deletions apps/ottabase-template-app-tanstack/ottabase/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export { accountsTable, authenticatorsTable, mediaTable, sessionsTable, usersTab
// APP-SPECIFIC TABLES
// ============================================================
export { changelogEntriesTable } from '../models/ChangelogEntry';
export { expenseGroupMembersTable, expenseGroupsTable, expensesTable, expenseSplitsTable } from '../models/Expense';
export { pageActionsTable, pageFeaturesTable, pagesTable, pageSectionsTable } from '../models/MarketingPage';
export { todosTable } from '../models/Todo';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from '@ottabase/ottaorm';
import { getEnabledPackageTables } from '../config.migrations';
import { changelogEntriesTable } from '../models/ChangelogEntry';
import { expenseGroupMembersTable, expenseGroupsTable, expensesTable, expenseSplitsTable } from '../models/Expense';
import { pageActionsTable, pageFeaturesTable, pagesTable, pageSectionsTable } from '../models/MarketingPage';
import { todosTable } from '../models/Todo';

Expand Down Expand Up @@ -67,6 +68,10 @@ export function getAllSchemas() {
pageSectionsTable,
pageFeaturesTable,
pageActionsTable,
expenseGroupsTable,
expenseGroupMembersTable,
expensesTable,
expenseSplitsTable,
todosTable,
};

Expand Down Expand Up @@ -113,6 +118,10 @@ export function getSchemaSummary() {
pageSectionsTable,
pageFeaturesTable,
pageActionsTable,
expenseGroupsTable,
expenseGroupMembersTable,
expensesTable,
expenseSplitsTable,
todosTable,
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { parseNaturalExpenseInput } from '../naturalExpenseParser';

describe('parseNaturalExpenseInput', () => {
it('parses fixed amount plus equal remainder splits', () => {
const result = parseNaturalExpenseInput(
'Osaka Haiku restaurant dinner on 29 may 10000 yen 5000 for Chris, rest shared equally between Kevin and dj',
{
now: new Date(2026, 2, 31),
knownMembers: ['Chris', 'Kevin', 'dj'],
},
);

expect(result.description).toBe('Osaka Haiku restaurant dinner');
expect(result.amount).toBe(10000);
expect(result.currency).toBe('JPY');
expect(new Date(result.expenseDate).getFullYear()).toBe(2026);
expect(new Date(result.expenseDate).getMonth()).toBe(4);
expect(new Date(result.expenseDate).getDate()).toBe(29);
expect(result.splits).toEqual([
expect.objectContaining({ memberName: 'Chris', amount: 5000, splitType: 'fixed' }),
expect.objectContaining({ memberName: 'Kevin', amount: 2500, splitType: 'equal' }),
expect.objectContaining({ memberName: 'dj', amount: 2500, splitType: 'equal' }),
]);
});

it('distributes odd remainders deterministically', () => {
const result = parseNaturalExpenseInput(
'Lunch 10001 yen 5000 for Chris, rest shared equally between Kevin and DJ',
{
now: new Date(2026, 2, 31),
},
);

expect(result.splits.map((split) => split.amount)).toEqual([5000, 2501, 2500]);
});

it('throws when total amount is missing', () => {
expect(() => parseNaturalExpenseInput('Dinner for Chris')).toThrow('Could not find a total amount');
});

it('extracts merchant names after an at phrase', () => {
const result = parseNaturalExpenseInput('The dinner at Osaka Haiku on 29 may 10000 yen', {
now: new Date(2026, 2, 31),
});

expect(result.merchant).toBe('Osaka Haiku');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
export interface ParsedExpenseSplit {
memberName: string;
amount: number;
splitType: 'fixed' | 'equal';
note?: string;
}

export interface ParsedExpenseInput {
description: string;
merchant?: string;
expenseDate: number;
amount: number;
currency: string;
paidByName?: string;
splits: ParsedExpenseSplit[];
confidence: number;
warnings: string[];
}

interface ParserOptions {
now?: Date;
knownMembers?: string[];
}

const MONTHS: Record<string, number> = {
jan: 0,
january: 0,
feb: 1,
february: 1,
mar: 2,
march: 2,
apr: 3,
april: 3,
may: 4,
jun: 5,
june: 5,
jul: 6,
july: 6,
aug: 7,
august: 7,
sep: 8,
sept: 8,
september: 8,
oct: 9,
october: 9,
nov: 10,
november: 10,
dec: 11,
december: 11,
};

const CURRENCY_ALIASES: Record<string, string> = {
yen: 'JPY',
jpy: 'JPY',
usd: 'USD',
dollar: 'USD',
dollars: 'USD',
eur: 'EUR',
euro: 'EUR',
euros: 'EUR',
inr: 'INR',
rupee: 'INR',
rupees: 'INR',
gbp: 'GBP',
pound: 'GBP',
pounds: 'GBP',
};

const MIN_CONFIDENCE = 0.4;
const BASE_CONFIDENCE = 0.55;
const FIXED_SPLIT_CONFIDENCE_BOOST = 0.15;
const EQUAL_SPLIT_CONFIDENCE_BOOST = 0.2;
// The first few words usually contain the vendor/place before category words like "dinner".
const MERCHANT_WORD_LIMIT = 3;

function normalizeName(name: string) {
return name.trim().replace(/\s+/g, ' ');
}

function canonicalMemberName(name: string, knownMembers: string[]) {
const normalized = normalizeName(name);
const match = knownMembers.find((member) => member.toLowerCase() === normalized.toLowerCase());
return match || normalized.replace(/^./, (char) => char.toUpperCase());
}

function parseIntegerAmount(value: string) {
return Math.round(Number(value.replace(/,/g, '')));
}

function parseDate(input: string, now: Date) {
const lower = input.toLowerCase();
const dateWithMonth = lower.match(/\bon\s+(\d{1,2})(?:st|nd|rd|th)?\s+([a-z]+)(?:\s+(\d{4}))?\b/);
if (dateWithMonth) {
const day = Number(dateWithMonth[1]);
const month = MONTHS[dateWithMonth[2]];
const year = dateWithMonth[3] ? Number(dateWithMonth[3]) : now.getFullYear();
if (month !== undefined && day >= 1 && day <= 31) {
return new Date(year, month, day).getTime();
}
}

const isoDate = lower.match(/\bon\s+(\d{4})-(\d{1,2})-(\d{1,2})\b/);
if (isoDate) {
return new Date(Number(isoDate[1]), Number(isoDate[2]) - 1, Number(isoDate[3])).getTime();
}

if (/\byesterday\b/.test(lower)) {
const date = new Date(now);
date.setDate(date.getDate() - 1);
return date.getTime();
}

if (/\btoday\b/.test(lower)) {
return now.getTime();
}

return now.getTime();
}

function removeParsedFragments(input: string) {
return input
.replace(/\bon\s+\d{1,2}(?:st|nd|rd|th)?\s+[a-z]+(?:\s+\d{4})?\b/gi, ' ')
.replace(/\bon\s+\d{4}-\d{1,2}-\d{1,2}\b/gi, ' ')
.replace(/\b(today|yesterday)\b/gi, ' ')
.replace(/\b\d[\d,]*(?:\.\d+)?\s*(yen|jpy|usd|dollars?|eur|euros?|inr|rupees?|gbp|pounds?)\b/gi, ' ')
.replace(/\b\d[\d,]*\s+(?:for|to)\s+[a-z][a-z .'-]*\b/gi, ' ')
.replace(/\b(rest|remainder|remaining)\s+(?:is\s+)?(?:shared\s+)?equally\s+(?:between|among|with)\s+.+$/i, ' ')
.replace(/\bpaid\s+by\s+[a-z][a-z .'-]*\b/gi, ' ')
.replace(/\s+[,.]+/g, ' ')
.replace(/[,.]+$/g, '')
.replace(/\s+/g, ' ')
.trim();
}

function splitNames(value: string, knownMembers: string[]) {
return value
.split(/,|\band\b|\+/i)
.map((name) => name.replace(/\b(rest|remainder|remaining|shared|equally|between|among|with)\b/gi, ''))
.map(normalizeName)
.filter(Boolean)
.map((name) => canonicalMemberName(name, knownMembers));
}

function extractMerchant(description: string) {
const locationMatch = description.match(/\bat\s+(.+)$/i);
const source = locationMatch?.[1] || description;
return source.split(/\s+/).slice(0, MERCHANT_WORD_LIMIT).join(' ');
}

export function parseNaturalExpenseInput(input: string, options: ParserOptions = {}): ParsedExpenseInput {
const now = options.now || new Date();
const knownMembers = options.knownMembers || [];
const warnings: string[] = [];
const normalizedInput = input.trim();
const lower = normalizedInput.toLowerCase();

const amountMatch = lower.match(
/\b(\d[\d,]*(?:\.\d+)?)\s*(yen|jpy|usd|dollars?|eur|euros?|inr|rupees?|gbp|pounds?)\b/,
);
if (!amountMatch) {
throw new Error('Could not find a total amount and currency. Try: "10000 yen".');
}

const amount = parseIntegerAmount(amountMatch[1]);
const currency = CURRENCY_ALIASES[amountMatch[2]] || amountMatch[2].toUpperCase();
const expenseDate = parseDate(normalizedInput, now);

const fixedSplits: ParsedExpenseSplit[] = [];
const fixedRegex =
/\b(\d[\d,]*)\s+(?:for|to)\s+([a-z][a-z .'-]*?)(?=\s*,|\s+and\s+\d|\s+rest\b|\s+remainder\b|\s+remaining\b|$)/gi;
let fixedMatch: RegExpExecArray | null;
while ((fixedMatch = fixedRegex.exec(normalizedInput)) !== null) {
fixedSplits.push({
memberName: canonicalMemberName(fixedMatch[2], knownMembers),
amount: parseIntegerAmount(fixedMatch[1]),
splitType: 'fixed',
note: 'Fixed amount from natural language input',
});
}

const equalNamesMatch = normalizedInput.match(
/\b(?:rest|remainder|remaining)\s+(?:is\s+)?(?:shared\s+)?equally\s+(?:between|among|with)\s+(.+)$/i,
);
const equalNames = equalNamesMatch ? splitNames(equalNamesMatch[1], knownMembers) : [];

const fixedTotal = fixedSplits.reduce((sum, split) => sum + split.amount, 0);
const remaining = amount - fixedTotal;
if (remaining < 0) {
throw new Error('Fixed split amounts exceed the total expense amount.');
}

const equalSplits: ParsedExpenseSplit[] = [];
if (equalNames.length > 0) {
const base = Math.floor(remaining / equalNames.length);
const remainder = remaining % equalNames.length;
equalNames.forEach((memberName, index) => {
// Keep integer currency units by assigning leftover units to earlier names deterministically.
equalSplits.push({
memberName,
amount: base + (index < remainder ? 1 : 0),
splitType: 'equal',
note: 'Equal share of remaining amount',
});
});
} else if (remaining > 0) {
warnings.push('No equal-share members were found for the remaining amount.');
}

const paidByMatch = normalizedInput.match(/\bpaid\s+by\s+([a-z][a-z .'-]*?)(?=\s*,|\s+on\b|$)/i);
const paidByName = paidByMatch ? canonicalMemberName(paidByMatch[1], knownMembers) : undefined;
const description = removeParsedFragments(normalizedInput) || 'Expense';
const merchant = extractMerchant(description);
const confidence = Math.max(
MIN_CONFIDENCE,
Math.min(
1,
BASE_CONFIDENCE +
(fixedSplits.length ? FIXED_SPLIT_CONFIDENCE_BOOST : 0) +
(equalSplits.length ? EQUAL_SPLIT_CONFIDENCE_BOOST : 0),
),
);

return {
description,
merchant,
expenseDate,
amount,
currency,
paidByName,
splits: [...fixedSplits, ...equalSplits],
confidence,
warnings,
};
}
Loading