🎉 init

This commit is contained in:
2026-07-01 15:43:18 +02:00
commit 2f492c55be
64 changed files with 11645 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { readIdentity, writeIdentity } from './identity'
function createMockStorage(): Storage {
const store = new Map<string, string>()
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
clear: () => store.clear(),
key: () => null,
get length() { return store.size },
}
}
describe('identity storage', () => {
it('returns null when nothing is stored', () => {
expect(readIdentity(createMockStorage())).toBeNull()
})
it('round-trips a written identity', () => {
const storage = createMockStorage()
writeIdentity(storage, { name: 'Alice' })
expect(readIdentity(storage)).toEqual({ name: 'Alice' })
})
it('ignores malformed JSON', () => {
const storage = createMockStorage()
storage.setItem('stb:identity', '{not json')
expect(readIdentity(storage)).toBeNull()
})
it('ignores an empty stored name', () => {
const storage = createMockStorage()
storage.setItem('stb:identity', JSON.stringify({ name: ' ' }))
expect(readIdentity(storage)).toBeNull()
})
it('overwrites a previously stored identity', () => {
const storage = createMockStorage()
writeIdentity(storage, { name: 'Alice' })
writeIdentity(storage, { name: 'Bob' })
expect(readIdentity(storage)).toEqual({ name: 'Bob' })
})
})
+26
View File
@@ -0,0 +1,26 @@
const STORAGE_KEY = 'stb:identity'
export interface Identity {
name: string
}
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
export function readIdentity(storage: StorageLike): Identity | null {
const raw = storage.getItem(STORAGE_KEY)
if (!raw) return null
try {
const parsed = JSON.parse(raw)
if (typeof parsed?.name !== 'string' || !parsed.name.trim()) return null
return { name: parsed.name }
}
catch {
return null
}
}
export function writeIdentity(storage: StorageLike, identity: Identity): void {
storage.setItem(STORAGE_KEY, JSON.stringify(identity))
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { readCachedPaymentLink, writeCachedPaymentLink } from './paymentLinkCache'
function createMockStorage(): Storage {
const store = new Map<string, string>()
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
clear: () => store.clear(),
key: () => null,
get length() { return store.size },
}
}
describe('payment link cache', () => {
it('returns null when nothing is cached', () => {
expect(readCachedPaymentLink(createMockStorage())).toBeNull()
})
it('round-trips a cached link', () => {
const storage = createMockStorage()
writeCachedPaymentLink(storage, 'https://paypal.me/alice')
expect(readCachedPaymentLink(storage)).toBe('https://paypal.me/alice')
})
it('overwrites a previously cached link', () => {
const storage = createMockStorage()
writeCachedPaymentLink(storage, 'https://paypal.me/alice')
writeCachedPaymentLink(storage, 'https://paypal.me/bob')
expect(readCachedPaymentLink(storage)).toBe('https://paypal.me/bob')
})
})
+11
View File
@@ -0,0 +1,11 @@
const STORAGE_KEY = 'stb:last-payment-link'
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
export function readCachedPaymentLink(storage: StorageLike): string | null {
return storage.getItem(STORAGE_KEY) || null
}
export function writeCachedPaymentLink(storage: StorageLike, link: string): void {
storage.setItem(STORAGE_KEY, link)
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { readVisitedPots, recordVisitedPot } from './visitedPots'
function createMockStorage(): Storage {
const store = new Map<string, string>()
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
clear: () => store.clear(),
key: () => null,
get length() { return store.size },
}
}
describe('visited pots storage', () => {
it('returns an empty list when nothing is stored', () => {
expect(readVisitedPots(createMockStorage())).toEqual([])
})
it('records a visited pot', () => {
const storage = createMockStorage()
recordVisitedPot(storage, { slug: 'abc', name: 'Lisbon Trip', currency: 'EUR' })
expect(readVisitedPots(storage)).toEqual([
expect.objectContaining({ slug: 'abc', name: 'Lisbon Trip', currency: 'EUR' }),
])
})
it('moves a re-visited pot to the front instead of duplicating it', () => {
const storage = createMockStorage()
recordVisitedPot(storage, { slug: 'abc', name: 'Lisbon Trip', currency: 'EUR' })
recordVisitedPot(storage, { slug: 'def', name: 'Ski Weekend', currency: 'CHF' })
recordVisitedPot(storage, { slug: 'abc', name: 'Lisbon Trip', currency: 'EUR' })
const pots = readVisitedPots(storage)
expect(pots).toHaveLength(2)
expect(pots[0]?.slug).toBe('abc')
})
it('updates the stored name and currency on revisit', () => {
const storage = createMockStorage()
recordVisitedPot(storage, { slug: 'abc', name: 'Old Name', currency: 'EUR' })
recordVisitedPot(storage, { slug: 'abc', name: 'New Name', currency: 'USD' })
expect(readVisitedPots(storage)).toEqual([
expect.objectContaining({ slug: 'abc', name: 'New Name', currency: 'USD' }),
])
})
it('ignores malformed JSON', () => {
const storage = createMockStorage()
storage.setItem('stb:visited-pots', '{not json')
expect(readVisitedPots(storage)).toEqual([])
})
it('caps the list at 20 entries, keeping the most recent', () => {
const storage = createMockStorage()
for (let i = 0; i < 25; i++) {
recordVisitedPot(storage, { slug: `pot-${i}`, name: `Pot ${i}`, currency: 'EUR' })
}
const pots = readVisitedPots(storage)
expect(pots).toHaveLength(20)
expect(pots[0]?.slug).toBe('pot-24')
expect(pots.some(p => p.slug === 'pot-0')).toBe(false)
})
})
+40
View File
@@ -0,0 +1,40 @@
const STORAGE_KEY = 'stb:visited-pots'
const MAX_ENTRIES = 20
export interface VisitedPot {
slug: string
name: string
currency: string
visitedAt: number
}
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
export function readVisitedPots(storage: StorageLike): VisitedPot[] {
const raw = storage.getItem(STORAGE_KEY)
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((item): item is VisitedPot =>
typeof item?.slug === 'string' && item.slug.trim() !== ''
&& typeof item?.name === 'string'
&& typeof item?.currency === 'string'
&& typeof item?.visitedAt === 'number',
)
}
catch {
return []
}
}
export function recordVisitedPot(storage: StorageLike, pot: Omit<VisitedPot, 'visitedAt'>): VisitedPot[] {
const rest = readVisitedPots(storage).filter(p => p.slug !== pot.slug)
const updated = [{ ...pot, visitedAt: Date.now() }, ...rest]
.sort((a, b) => b.visitedAt - a.visitedAt)
.slice(0, MAX_ENTRIES)
storage.setItem(STORAGE_KEY, JSON.stringify(updated))
return updated
}