forked from cartsnitch/cartsnitch
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d167c7a1fc | |||
| ac3953de47 | |||
| c9c07b7e1d | |||
| bf4965adf6 | |||
| b964649fd5 |
@@ -111,8 +111,6 @@ jobs:
|
|||||||
build-and-push-auth:
|
build-and-push-auth:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
needs: [lint, test]
|
needs: [lint, test]
|
||||||
outputs:
|
|
||||||
calver_tag: ${{ steps.calver.outputs.version }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -163,7 +161,7 @@ jobs:
|
|||||||
|
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
needs: [build-and-push, build-and-push-auth]
|
needs: [build-and-push]
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
steps:
|
steps:
|
||||||
- name: Generate GitHub App token
|
- name: Generate GitHub App token
|
||||||
@@ -193,7 +191,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd infra/apps/overlays/dev
|
cd infra/apps/overlays/dev
|
||||||
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
|
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
|
||||||
kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
|
|
||||||
|
|
||||||
- name: Commit and push to infra
|
- name: Commit and push to infra
|
||||||
run: |
|
run: |
|
||||||
@@ -201,5 +198,5 @@ jobs:
|
|||||||
git config user.name "cartsnitch-ci[bot]"
|
git config user.name "cartsnitch-ci[bot]"
|
||||||
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
||||||
git add apps/overlays/dev/kustomization.yaml
|
git add apps/overlays/dev/kustomization.yaml
|
||||||
git commit -m "ci(dev): update cartsnitch and auth images to ${{ needs.build-and-push.outputs.calver_tag }}"
|
git commit -m "ci(dev): update cartsnitch image to ${{ needs.build-and-push.outputs.calver_tag }}"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|||||||
@@ -1 +1,45 @@
|
|||||||
# CartSnitch
|
# CartSnitch Monorepo
|
||||||
|
|
||||||
|
CartSnitch is a self-hosted grocery price intelligence platform. This repo consolidates the core services and the flagship frontend PWA.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Directory | Service | Purpose |
|
||||||
|
|-----------|---------|---------|
|
||||||
|
| `/` (root) | **Frontend** | React 18 PWA — mobile-first price intelligence UI |
|
||||||
|
| `api/` | **API Gateway** | FastAPI — frontend-facing REST API |
|
||||||
|
| `common/` | **Common** | Shared Python models, schemas, Alembic migrations |
|
||||||
|
| `receiptwitness/` | **ReceiptWitness** | Purchase ingestion via retailer scrapers |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Frontend (root)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
npm run build # production build
|
||||||
|
npm run test # unit tests (Vitest)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python Services
|
||||||
|
|
||||||
|
Each Python service uses [uv](https://github.com/astral-sh/uv) and has its own `pyproject.toml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api # or common / receiptwitness
|
||||||
|
uv sync
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
- **Never push directly to main.** Always open a PR from a feature branch.
|
||||||
|
- Branch naming: `feature/<description>` or `fix/<description>`
|
||||||
|
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
For full details see [CLAUDE.md](./CLAUDE.md) or the per-service `CLAUDE.md` in each subdirectory.
|
||||||
|
|
||||||
|
CartSnitch is a polyrepo-style monorepo: each service can be built and deployed independently, but sharing code between `common/` and the other Python services is done via local path dependencies in `pyproject.toml`.
|
||||||
|
|||||||
+1
-29
@@ -1,36 +1,8 @@
|
|||||||
import { createAuthClient } from "better-auth/react"
|
import { createAuthClient } from "better-auth/react"
|
||||||
import type { BetterFetchPlugin } from "@better-fetch/fetch"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps 'name' -> 'display_name' in register requests to match the API's RegisterRequest schema.
|
|
||||||
*/
|
|
||||||
const displayNameMapper: BetterFetchPlugin = {
|
|
||||||
id: "display-name-mapper",
|
|
||||||
name: "display-name-mapper",
|
|
||||||
hooks: {
|
|
||||||
onRequest: async (context) => {
|
|
||||||
const url = typeof context.url === "string" ? context.url : context.url.pathname
|
|
||||||
if (
|
|
||||||
url.endsWith("/auth/register") &&
|
|
||||||
context.method === "POST" &&
|
|
||||||
context.body &&
|
|
||||||
"name" in context.body
|
|
||||||
) {
|
|
||||||
context.body = {
|
|
||||||
...context.body,
|
|
||||||
display_name: context.body.name as string,
|
|
||||||
name: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: import.meta.env.VITE_AUTH_URL || "",
|
baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001",
|
||||||
basePath: "/auth",
|
basePath: "/auth",
|
||||||
fetchPlugins: [displayNameMapper],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { useSession, signIn, signUp, signOut } = authClient
|
export const { useSession, signIn, signUp, signOut } = authClient
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { formatCurrency } from '../formatCurrency';
|
|
||||||
|
|
||||||
describe('formatCurrency', () => {
|
|
||||||
it('formats 0 cents as $0.00', () => {
|
|
||||||
expect(formatCurrency(0)).toBe('$0.00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats 199 cents as $1.99', () => {
|
|
||||||
expect(formatCurrency(199)).toBe('$1.99');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats 10000 cents as $100.00', () => {
|
|
||||||
expect(formatCurrency(10000)).toBe('$100.00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles negative values', () => {
|
|
||||||
expect(formatCurrency(-500)).toBe('-$5.00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles large numbers', () => {
|
|
||||||
expect(formatCurrency(99999999)).toBe('$999,999.99');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports custom locale', () => {
|
|
||||||
expect(formatCurrency(1999, 'de-DE', 'EUR')).toContain('19,99');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports custom currency', () => {
|
|
||||||
const result = formatCurrency(1000, 'en-US', 'EUR');
|
|
||||||
expect(result).toContain('10.00');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
||||||
import { formatDate } from '../formatDate';
|
|
||||||
|
|
||||||
describe('formatDate', () => {
|
|
||||||
describe('short style', () => {
|
|
||||||
it('formats an ISO date string', () => {
|
|
||||||
const result = formatDate('2024-03-15', 'short');
|
|
||||||
expect(result).toMatch(/Mar 15, 2024/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats a Date object', () => {
|
|
||||||
const result = formatDate(new Date('2024-03-15'), 'short');
|
|
||||||
expect(result).toMatch(/Mar 15, 2024/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('long style', () => {
|
|
||||||
it('formats with weekday and full month name', () => {
|
|
||||||
const result = formatDate('2024-03-15', 'long');
|
|
||||||
expect(result).toMatch(/Friday/);
|
|
||||||
expect(result).toMatch(/March/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('relative style', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns "just now" for very recent dates', () => {
|
|
||||||
const now = new Date('2024-01-01T12:00:00Z');
|
|
||||||
vi.setSystemTime(now);
|
|
||||||
const result = formatDate(new Date('2024-01-01T11:59:59Z'), 'relative');
|
|
||||||
expect(result).toBe('just now');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns minutes ago', () => {
|
|
||||||
const now = new Date('2024-01-01T12:00:00Z');
|
|
||||||
vi.setSystemTime(now);
|
|
||||||
const result = formatDate(new Date('2024-01-01T11:45:00Z'), 'relative');
|
|
||||||
expect(result).toBe('15m ago');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns hours ago', () => {
|
|
||||||
const now = new Date('2024-01-01T12:00:00Z');
|
|
||||||
vi.setSystemTime(now);
|
|
||||||
const result = formatDate(new Date('2024-01-01T09:00:00Z'), 'relative');
|
|
||||||
expect(result).toBe('3h ago');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns days ago', () => {
|
|
||||||
const now = new Date('2024-01-05T12:00:00Z');
|
|
||||||
vi.setSystemTime(now);
|
|
||||||
const result = formatDate(new Date('2024-01-01T12:00:00Z'), 'relative');
|
|
||||||
expect(result).toBe('4d ago');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { getStore, getStoreName, STORE_SLUGS } from '../storeSlugs';
|
|
||||||
|
|
||||||
describe('storeSlugs', () => {
|
|
||||||
describe('STORE_SLUGS constant', () => {
|
|
||||||
it('contains meijer, kroger, and target', () => {
|
|
||||||
expect(STORE_SLUGS).toHaveProperty('meijer');
|
|
||||||
expect(STORE_SLUGS).toHaveProperty('kroger');
|
|
||||||
expect(STORE_SLUGS).toHaveProperty('target');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getStore', () => {
|
|
||||||
it('returns store data for known slug', () => {
|
|
||||||
const store = getStore('meijer');
|
|
||||||
expect(store).toEqual({
|
|
||||||
name: 'Meijer',
|
|
||||||
color: '#e31837',
|
|
||||||
icon: '/icons/stores/meijer.svg',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for unknown slug', () => {
|
|
||||||
expect(getStore('unknown-store')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is case insensitive', () => {
|
|
||||||
expect(getStore('KROGER')).toBeTruthy();
|
|
||||||
expect(getStore('Target')).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getStoreName', () => {
|
|
||||||
it('returns store name for known slug', () => {
|
|
||||||
expect(getStoreName('kroger')).toBe('Kroger');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns raw slug for unknown store', () => {
|
|
||||||
expect(getStoreName('unknown-store')).toBe('unknown-store');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is case insensitive', () => {
|
|
||||||
expect(getStoreName('TARGET')).toBe('Target');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export function formatCurrency(
|
|
||||||
cents: number,
|
|
||||||
locale = 'en-US',
|
|
||||||
currency = 'USD'
|
|
||||||
): string {
|
|
||||||
return new Intl.NumberFormat(locale, {
|
|
||||||
style: 'currency',
|
|
||||||
currency,
|
|
||||||
}).format(cents / 100);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
export function formatDate(
|
|
||||||
date: string | Date,
|
|
||||||
style: 'short' | 'long' | 'relative' = 'short'
|
|
||||||
): string {
|
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
|
||||||
|
|
||||||
if (style === 'short') {
|
|
||||||
return d.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (style === 'long') {
|
|
||||||
return d.toLocaleDateString('en-US', {
|
|
||||||
weekday: 'long',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// relative
|
|
||||||
const diff = Date.now() - d.getTime();
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
|
||||||
if (seconds < 60) return 'just now';
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export const STORE_SLUGS: Record<string, { name: string; color: string; icon: string }> = {
|
|
||||||
meijer: { name: 'Meijer', color: '#e31837', icon: '/icons/stores/meijer.svg' },
|
|
||||||
kroger: { name: 'Kroger', color: '#0033a0', icon: '/icons/stores/kroger.svg' },
|
|
||||||
target: { name: 'Target', color: '#cc0000', icon: '/icons/stores/target.svg' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getStore(slug: string) {
|
|
||||||
return STORE_SLUGS[slug.toLowerCase()] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoreName(slug: string): string {
|
|
||||||
return getStore(slug)?.name ?? slug;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user