forked from cartsnitch/cartsnitch
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b22f2ee614 | |||
| 4f42247bf2 | |||
| 4c29d8a241 | |||
| 6b6b9e7d01 | |||
| 835aff3522 | |||
| 5588c1b5d8 | |||
| c5ed863ab1 | |||
| 8d0552f73f | |||
| 3a75ee7aee | |||
| 30d670a257 | |||
| cfa4d8fa91 | |||
| 39e8d5c9f9 |
@@ -111,6 +111,8 @@ 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:
|
||||||
@@ -161,7 +163,7 @@ jobs:
|
|||||||
|
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
needs: [build-and-push]
|
needs: [build-and-push, build-and-push-auth]
|
||||||
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
|
||||||
@@ -191,6 +193,7 @@ 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: |
|
||||||
@@ -198,5 +201,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 image to ${{ needs.build-and-push.outputs.calver_tag }}"
|
git commit -m "ci(dev): update cartsnitch and auth images to ${{ needs.build-and-push.outputs.calver_tag }}"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|||||||
@@ -1,45 +1 @@
|
|||||||
# CartSnitch Monorepo
|
# CartSnitch
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# CartSnitch UAT Runbook v1
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Author:** Savannah Savings, CTO
|
||||||
|
**Date:** 2026-03-30
|
||||||
|
**Effective:** Immediately upon Phase 1 completion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Defect Severity Classification
|
||||||
|
|
||||||
|
Every defect discovered during UAT **must** be classified by severity and priority before triage.
|
||||||
|
|
||||||
|
### Severity Levels
|
||||||
|
|
||||||
|
| Severity | Definition | Examples |
|
||||||
|
|----------|-----------|----------|
|
||||||
|
| **S1 — Critical** | Blocks all users from completing a core journey. System is down, data is lost, or security is breached. | Login page crashes for all users; purchase data deleted; auth tokens exposed in response |
|
||||||
|
| **S2 — High** | Blocks a major user flow for a significant portion of users. Core feature is broken but workarounds may exist. | Registration fails for email addresses with `+` character; price alerts never trigger; store comparison shows wrong prices |
|
||||||
|
| **S3 — Medium** | Feature is degraded but usable. User can complete the journey with friction. | Date formatting shows raw ISO string instead of friendly date; slow page load (>5s) on product detail; search results not sorted correctly |
|
||||||
|
| **S4 — Low** | Cosmetic issue, minor UI inconsistency, or edge case with minimal user impact. | Button text truncated on narrow screens; extra whitespace in footer; tooltip shows on hover but not on focus |
|
||||||
|
|
||||||
|
### Priority Levels
|
||||||
|
|
||||||
|
Priority determines **when** the defect must be fixed. Priority is set by the CTO based on severity, business impact, and sprint capacity.
|
||||||
|
|
||||||
|
| Priority | SLA | When to Use |
|
||||||
|
|----------|-----|------------|
|
||||||
|
| **P0 — Fix Now** | Triage within 1 hour, fix deployed within 4 hours | S1 defects, any security vulnerability, data integrity issues |
|
||||||
|
| **P1 — Fix This Sprint** | Triage within 4 hours, fix in current sprint | S2 defects blocking upcoming release, S1 defects with viable workaround |
|
||||||
|
| **P2 — Fix Next Sprint** | Triage within 24 hours, scheduled for next sprint | S3 defects, S2 defects with easy workarounds |
|
||||||
|
| **P3 — Backlog** | Triage within 48 hours, prioritized against backlog | S4 defects, minor improvements, nice-to-haves |
|
||||||
|
|
||||||
|
### Defect Report Template
|
||||||
|
|
||||||
|
Every defect filed during UAT must include:
|
||||||
|
|
||||||
|
```
|
||||||
|
**Title:** [Short description]
|
||||||
|
**Severity:** S1/S2/S3/S4
|
||||||
|
**Priority:** P0/P1/P2/P3 (set by CTO at triage)
|
||||||
|
**Journey:** [Which user journey — J1 through J10]
|
||||||
|
**Environment:** [Dev / Prod, deployed image tag]
|
||||||
|
**Steps to Reproduce:**
|
||||||
|
1. Navigate to ...
|
||||||
|
2. Click ...
|
||||||
|
3. Enter ...
|
||||||
|
**Expected Result:** ...
|
||||||
|
**Actual Result:** ...
|
||||||
|
**Screenshots/Logs:** [Attach or link]
|
||||||
|
**Browser/Device:** [e.g., Chromium 124, mobile viewport 390x844]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. UAT Entry Criteria
|
||||||
|
|
||||||
|
UAT **must not begin** until ALL of the following are satisfied. Checkout Charlie verifies these before opening the UAT gate.
|
||||||
|
|
||||||
|
| # | Criterion | Verified By |
|
||||||
|
|---|-----------|------------|
|
||||||
|
| E1 | CI pipeline passes on the merged commit (lint, type-check, unit tests, build) | GitHub Actions (automated) |
|
||||||
|
| E2 | Docker image is built and pushed to GHCR with a CalVer tag | GitHub Actions (automated) |
|
||||||
|
| E3 | Dev environment is deployed and accessible at `cartsnitch.dev.farh.net` | Flux reconciliation + health check |
|
||||||
|
| E4 | All Playwright E2E tests pass in CI | GitHub Actions (automated) |
|
||||||
|
| E5 | No open S1/S2 defects from previous UAT cycle | Checkout Charlie (manual check) |
|
||||||
|
| E6 | PR has been reviewed and approved by QA (Checkout Charlie) and CTO (Savannah Savings) | GitHub PR approvals |
|
||||||
|
| E7 | PR has been merged to main by CEO (Coupon Carl) | GitHub merge event |
|
||||||
|
| E8 | Acceptance criteria for the feature/change are documented in the Paperclip issue | Checkout Charlie (manual check) |
|
||||||
|
|
||||||
|
**If any entry criterion is not met**, UAT is blocked. Checkout Charlie must comment on the Paperclip issue specifying which criteria failed and assign back to the responsible party.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UAT Exit Criteria
|
||||||
|
|
||||||
|
UAT is **complete** only when ALL of the following are satisfied. Rollback Rhonda verifies these before signing off.
|
||||||
|
|
||||||
|
| # | Criterion | Verified By |
|
||||||
|
|---|-----------|------------|
|
||||||
|
| X1 | All 10 critical user journeys (J1-J10) have been executed | Rollback Rhonda (full regression) |
|
||||||
|
| X2 | Zero open S1 (Critical) defects | Defect tracker |
|
||||||
|
| X3 | Zero open S2 (High) defects, OR CTO has granted a documented exception | Defect tracker + CTO sign-off |
|
||||||
|
| X4 | All S3/S4 defects are logged and triaged (not necessarily fixed) | Defect tracker |
|
||||||
|
| X5 | 100% test execution rate -- every test case was run, none skipped | Rollback Rhonda's UAT report |
|
||||||
|
| X6 | Accessibility scan (axe-core) reports zero critical violations | Automated in E2E suite |
|
||||||
|
| X7 | Lighthouse performance score >= 50, accessibility score >= 90 | Lighthouse CI |
|
||||||
|
| X8 | Written sign-off from Rollback Rhonda confirming all criteria met | Paperclip comment on issue |
|
||||||
|
|
||||||
|
**If any exit criterion is not met**, the release is blocked. Rollback Rhonda must:
|
||||||
|
1. File defects for all failures using the Defect Report Template above.
|
||||||
|
2. Comment on the Paperclip issue specifying which exit criteria failed.
|
||||||
|
3. Assign back to CTO for triage and redistribution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UAT Execution Procedure
|
||||||
|
|
||||||
|
### 4.1 Pre-UAT (Checkout Charlie)
|
||||||
|
|
||||||
|
1. Verify all entry criteria (E1-E8) are met.
|
||||||
|
2. Comment on the Paperclip issue: "UAT gate open -- all entry criteria verified."
|
||||||
|
3. Assign to Rollback Rhonda with status todo.
|
||||||
|
|
||||||
|
### 4.2 UAT Execution (Rollback Rhonda)
|
||||||
|
|
||||||
|
1. **Full regression run** -- execute ALL 10 user journeys against cartsnitch.dev.farh.net. No partial runs. No exceptions.
|
||||||
|
2. For each journey, verify:
|
||||||
|
- All interactive elements respond correctly (buttons, forms, links, toggles)
|
||||||
|
- State transitions are correct (auth state, data mutations, navigation)
|
||||||
|
- Error states are handled gracefully (invalid input, network failures)
|
||||||
|
- Accessibility scan passes (axe-core integrated in Playwright)
|
||||||
|
3. Log results for each journey: PASS / FAIL with details.
|
||||||
|
4. File defects immediately for any failures.
|
||||||
|
5. Complete the UAT report with execution results.
|
||||||
|
|
||||||
|
### 4.3 Post-UAT Sign-Off
|
||||||
|
|
||||||
|
1. If all exit criteria (X1-X8) are met:
|
||||||
|
- Rollback Rhonda posts sign-off comment: "UAT PASSED -- all exit criteria met."
|
||||||
|
- Production promotion is automated via Flux on UAT pass.
|
||||||
|
2. If any exit criterion fails:
|
||||||
|
- Rollback Rhonda posts failure comment with specific failures.
|
||||||
|
- CTO triages defects and redistributes to engineers.
|
||||||
|
- After fixes are merged, UAT restarts from 4.1 (full cycle).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Critical User Journeys Reference
|
||||||
|
|
||||||
|
| ID | Journey | Key Interactions |
|
||||||
|
|----|---------|-----------------|
|
||||||
|
| J1 | Registration -> Login -> Dashboard | Form submission, auth state, redirect |
|
||||||
|
| J2 | Login -> Browse Products -> View Detail -> Price Chart | Search, navigation, data visualization |
|
||||||
|
| J3 | Login -> Purchases -> Purchase Detail -> Product Link | List navigation, detail view, cross-linking |
|
||||||
|
| J4 | Login -> Connect Store Account -> Verify Connection | OAuth flow, external integration |
|
||||||
|
| J5 | Login -> Create Price Alert -> View -> Delete Alert | CRUD operations, confirmation dialogs |
|
||||||
|
| J6 | Login -> Browse Coupons -> Copy Code | Clipboard interaction, toast feedback |
|
||||||
|
| J7 | Login -> Settings -> Toggle Preferences -> Sign Out | Checkbox toggles, theme switch, session termination |
|
||||||
|
| J8 | Login -> Store Comparison -> Compare Prices | Data comparison, sorting, price display |
|
||||||
|
| J9 | Forgot Password Flow | Email input, validation, redirect |
|
||||||
|
| J10 | Unauth Access -> Redirect to Login | Route protection, redirect behavior |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Revision History
|
||||||
|
|
||||||
|
| Version | Date | Author | Changes |
|
||||||
|
|---------|------|--------|---------|
|
||||||
|
| 1.0 | 2026-03-30 | Savannah Savings | Initial runbook -- defect taxonomy, entry/exit criteria, execution procedure |
|
||||||
|
|
||||||
@@ -3,10 +3,9 @@ FROM python:3.12-slim AS build
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# git is required to install cartsnitch-common from GitHub; build-essential and
|
# build-essential and libpq-dev are needed to compile any C-extension wheels
|
||||||
# libpq-dev are needed to compile any C-extension wheels (e.g. psycopg2 fallback)
|
# (e.g. psycopg2 fallback). git is no longer needed here.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git \
|
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
build-essential \
|
build-essential \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -14,13 +13,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
COPY pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
|
||||||
# cartsnitch-common is not on PyPI — install it directly from GitHub, then
|
# cartsnitch-common is not on PyPI — install it from the local monorepo common/
|
||||||
# install the rest of the package dependencies in a single resolver pass so
|
# directory. The common/ dir is the cartsnitch-common Python package.
|
||||||
# pip can satisfy the cartsnitch-common>=0.1.0 constraint declared in
|
COPY common/ /tmp/common/
|
||||||
# pyproject.toml without hitting PyPI for it.
|
RUN pip install --no-cache-dir --prefix=/install /tmp/common/ .
|
||||||
RUN pip install --no-cache-dir --prefix=/install \
|
|
||||||
"cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" \
|
|
||||||
.
|
|
||||||
|
|
||||||
# Stage 2: Production image with Playwright + Chromium
|
# Stage 2: Production image with Playwright + Chromium
|
||||||
FROM python:3.12-slim AS prod
|
FROM python:3.12-slim AS prod
|
||||||
|
|||||||
+29
-1
@@ -1,8 +1,36 @@
|
|||||||
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 ?? "http://localhost:3001",
|
baseURL: import.meta.env.VITE_AUTH_URL || "",
|
||||||
basePath: "/auth",
|
basePath: "/auth",
|
||||||
|
fetchPlugins: [displayNameMapper],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { useSession, signIn, signUp, signOut } = authClient
|
export const { useSession, signIn, signUp, signOut } = authClient
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function formatCurrency(
|
||||||
|
cents: number,
|
||||||
|
locale = 'en-US',
|
||||||
|
currency = 'USD'
|
||||||
|
): string {
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
}).format(cents / 100);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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