Building Spinder: Progressive Web App with min
My reason for building Spinder was to more easily track expenses into categories such as for Amazon, groceries, and other buckets so that I could focus on saving money and getting rid of unnecessary expenses.
Financial Insights
Spinder operates entirely in the browser, using localStorage for data persistence. The application's core functionality revolves around three key features:
- Import: Users can upload transaction data from bank statements
- Categorization: Transactions are automatically organized into "buckets" (categories)
- Filtering: Time based filters, search, and category based views provide spending insights
Sample Data
Spinder has a sample data set for using the app before uploading your own transactional data. Instead of generic placeholder transactions, the app includes a medieval themed CSV file featuring a knight's expenses for slaying a dragon. Transactions include entries like:
Sword sharpening and polishing,-45.99,Debit
Armor repair (dragon claw marks),-120.50,Debit
Dragon-slaying insurance premium,-250.00,Debit
Tournament prize money,500.00,Credit
I threw in that medieval knight sample data for a few reasons. It shows off all the different kinds of transactions the app can handle, gives people a fun and obvious sense of what the CSV format should look like, and makes it super easy to play around with the whole flow including uploading, setting up buckets, and searching without having to mess with real bank statements right away.
Keeping Data Local
The application is truly client only, with no server component after the first visit and page load. Here's how it achieves this:
Service Worker
self.addEventListener('fetch', event => {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
} else {
return fetch(event.request).then(response => {
cache.put(event.request.url, response.clone());
return response;
});
}
});
})
);
});
Local Data Storage
All transaction data is stored in the browser's localStorage:
export function saveTransactions(transactions: Transaction[]): void {
localStorage.setItem("transactions", JSON.stringify(transactions));
}
export function loadTransactions(): Transaction[] {
const data = localStorage.getItem("transactions");
if (data) {
const parsed = JSON.parse(data);
return parsed.map((item: any) => Transaction.parse(item));
}
return [];
}
This approach means:
- Zero external dependencies: No API calls, no cloud storage
- Complete user control: Data can be cleared through browser settings
- Offline functionality: Works without internet connection
The Bucket System
Spinder uses text-based pattern matching to automatically organize transactions.
Bucket Data Structure
export const Bucket = z.object({
name: z.string(),
filterTexts: z.array(z.string()),
});
For example, a "Food" bucket might include filters like:
- "restaurant"
- "grocery"
- "cafe"
- "deli"
Categorization Logic
export function categorizeTransaction(description: string, buckets: Bucket[]): string | null {
const lowerDesc = description.toLowerCase();
for (const bucket of buckets) {
if (bucket.filterTexts.some(filter => lowerDesc.includes(filter.toLowerCase()))) {
return bucket.name;
}
}
return null;
}Lit and TypeScript
Spinder is built using Lit, a modern web components library that provides a reactive, TypeScript first development experience. The component architecture follows a clear hierarchy:
App Level Component
@customElement("spinder-app")
export class SpinderApp extends LitElement {
routes: RouteConfig[] = routes;
currentRoute: RouteConfig | null = this.determineRouteName();
override render(): TemplateResult {
return html`${pageContent}`;
}
}
Page Components
@customElement("spinder-home-page")
export class SpinderHomePage extends SpinderAppProvider {
override render(): TemplateResult {
return html`...`;
}
}
State Management
// Dispatching events
this.dispatchEvent(new UpdateTransactionsEvent({ transactions: allTransactions }));
// Consuming context
@consume({ context: transactionContext, subscribe: true })
transactions!: Transaction[];
This architecture provides:
- Reusability: Components can be used across different pages
- Separation of Concerns: Data logic is separated from presentation
- Type Safety: TypeScript ensures component interfaces are correct
Zod Powered Type Validation
export const Transaction = z.object({
details: z.string(),
postingDate: z.string(),
description: z.string(),
amount: z.number(),
type: z.string(),
balance: z.string().optional(),
checkOrSlipNumber: z.string().optional(),
});
This approach provides:
- Runtime Safety: Invalid data is caught and handled gracefully
- TypeScript Integration: Schemas generate TypeScript types automatically
- Validation: CSV parsing includes Zod validation to ensure data integrity
When parsing CSV files, the application validates each transaction:
const transaction = Transaction.parse({
details: values[0] || "",
postingDate: values[1] || "",
description: values[2] || "",
amount: parseFloat(values[3]) || 0,
type: values[4] || "",
balance: values[5] || undefined,
checkOrSlipNumber: values[6] || undefined,
});
Component Communication
// Event definitions
export class UpdateTransactionsEvent extends Event {
static eventName = "update-transactions";
transactions: Transaction[];
constructor(detail: { transactions: Transaction[] }) {
super(UpdateTransactionsEvent.eventName, { bubbles: true, composed: true });
this.transactions = detail.transactions;
}
}
// Event dispatching
this.dispatchEvent(new UpdateTransactionsEvent({ transactions: allTransactions }));
// Event handling
document.addEventListener(UpdateTransactionsEvent.eventName, (event: Event) => {
const customEvent = event as UpdateTransactionsEvent;
this.transactions = customEvent.transactions;
});
This pattern enables:
- Loose Coupling: Components don't need direct references to each other
- Flexibility: New components can listen to existing events
- Testability: Events can be easily mocked and tested
Progressive Web App Features
Service Worker
if ("serviceWorker" in navigator && window.location.hostname !== "localhost") {
navigator.serviceWorker.register("/sw.js");
}
Web App Manifest
{
"name": "Spinder",
"short_name": "Spinder",
"start_url": "/",
"display": "standalone",
"theme_color": "#1e90ff",
"icons": [...]
}
Offline Support
The service worker caches static assets, allowing the app to work offline after initial load.
Conclusion
Spinder demonstrates that modern web technologies can create useful applications in simple and easy to host way with offline availability while avoiding complex layers of frameworks and dependencies by leveraging web components, progressive web app features, and client-side storage.
For developers interested in building similar applications, Spinder serves as a blueprint for:
- Privacy-first architecture
- Modern component development
- PWA implementation
- Type-safe data handling
Spinder is available at spinder.alexlockhart.me and the source code is on GitHub at github.com/megazear7/spinder.


