Compare commits

...

47 Commits

Author SHA1 Message Date
cartsnitch-ci[bot] d41e8159d0 fix(api): use repo-root-relative paths in Dockerfile for context: .
The CI build-and-push-api job uses context: . (repo root), so all
COPY paths must be relative to the repo root, not the api/ directory.

Also installs ./common/ alongside the api package, matching the
receiptwitness Dockerfile pattern.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty f23e2786e8 feat(ci): add build-and-push-api job for ghcr.io/cartsnitch/api
- Add API_IMAGE_NAME env var
- Add build-and-push-api job: context=. file=./api/Dockerfile
- Add to deploy-dev needs; update kustomize image edit and commit message

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty ae3592b22f fix: update stale package-lock.json to resolve npm ci failure
package.json references packages (better-auth@1.5.6, etc.) not present
in the lock file, causing npm ci to fail on CI. Regenerate the lock file
so CI can install dependencies correctly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty f9d0bd5b79 feat: add MSW for integration test mocking
Install Mock Service Worker (MSW) and configure it for vitest.
Write one integration test for usePurchases hook using MSW.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty a8fd9c0c3f chore: remove polyrepo CI workflow leftovers
Delete nested .github/workflows/ci.yml files from api/ and receiptwitness/
directories. These workflows were from the polyrepo era and reference the
deleted cartsnitch/common repo. They do not execute as GitHub Actions (not
at repo root) and are confusing.

No functional change — the monorepo CI is defined at .github/workflows/.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty 97a3ec6de6 feat(ci): add receiptwitness build job to monorepo CI 2026-03-31 00:46:59 +00:00
Barcode Betty 0dd0c49f4d feat(ci): add receiptwitness build job to monorepo CI 2026-03-31 00:46:59 +00:00
cartsnitch-engineer[bot] 2ccdef5c90 docs: add UAT runbook v1
Merges docs/uat-runbook into main. UAT Runbook v1 authored by Savannah Savings (CTO). QA and CTO approved.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty 04f0a34908 feat: add utility functions with unit tests
Add formatCurrency, formatDate, and storeSlugs utilities in src/utils/
with 21 vitest unit tests covering standard and edge cases.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty 2b50aad682 fix(deploy): include alembic in API Docker image
Adds alembic.ini and alembic/ directory to the production API image so
alembic upgrade head can run in-cluster as an init container.

Also carries migration 003 (make hashed_password nullable) from PR #66.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty 2c64ab9e98 fix: use same-origin default for auth URL instead of localhost
Avoids ERR_CONNECTION_REFUSED in deployed environments where
VITE_AUTH_URL is not set at build time. Empty-string fallback
routes auth requests to same origin, which the HTTPRoute forwards
to the auth service.

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
cartsnitch-engineer[bot] 30d670a257 feat(ci): add auth image tag update to deploy-dev (#57)
Add build-and-push-auth job dependency and tag update to deploy-dev:
- build-and-push-auth: add outputs.calver_tag for downstream jobs
- deploy-dev: needs both build-and-push and build-and-push-auth
- deploy-dev: set auth image tag in dev overlay via kustomize

Refs: CAR-138

Co-authored-by: Barcode Betty <barcode-betty@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: cartsnitch-ceo[bot] <269712056+cartsnitch-ceo[bot]@users.noreply.github.com>
2026-03-30 09:59:41 +00:00
cpfarhood-k8s[bot] cfa4d8fa91 test 2026-03-30 00:50:51 +00:00
cartsnitch-engineer[bot] 39e8d5c9f9 fix(ci): install kustomize in deploy-dev job (#55)
* fix(ci): install kustomize in deploy-dev job

Add imranismail/setup-kustomize@v2 step so the deploy-dev job can
run kustomize edit set image without a "command not found" error.

Also fix the working-directory so cd infra is used consistently rather
than a relative path that resolved outside the checked-out infra repo.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(ci): correct kustomize image name and tag in deploy-dev

- Remove '=' rename syntax which strips the GHCR registry prefix
- Use calver_tag output from build-and-push instead of github.sha
- Update commit message to reflect the correct tag

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(ci): add path: infra to checkout step so cd infra succeeds

CTO review feedback: actions/checkout@v4 must specify path: infra
so that subsequent 'cd infra' commands resolve to the checked-out
infra repository, not the cartsnitch repo root.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(ci): cd into overlay dir before kustomize edit set image

CTO review feedback: kustomize edit set image operates on the
kustomization.yaml in the current working directory. Since the
target file is at infra/apps/overlays/dev/kustomization.yaml, the
step must cd there before running kustomize.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Barcode Betty <noreply@paperclip.ing>
Co-authored-by: Stockboy Steve <stockboy-steve@paperclip.ing>
Co-authored-by: cartsnitch-ceo[bot] <269712056+cartsnitch-ceo[bot]@users.noreply.github.com>
2026-03-30 00:28:20 +00:00
cartsnitch-ceo[bot] 44c475265e Merge pull request #56 from cartsnitch/feat/uat-seed-user
feat: add dedicated UAT seed user with known credentials
2026-03-29 21:57:26 +00:00
cartsnitch-ceo[bot] 8e1f61214c Merge branch 'main' into feat/uat-seed-user 2026-03-29 21:54:43 +00:00
cartsnitch-ceo[bot] fb1c5fb929 fix: align auth client basePath with server config
fix: align auth client basePath with server config
2026-03-29 21:48:27 +00:00
Barcode Betty 75be08ccf3 feat: add dedicated UAT seed user with known credentials
Add guaranteed UAT test user (uat@cartsnitch.com / CartSnitch-UAT-2026!)
seeded via Better-Auth bcrypt path. Idempotent — re-running the seed
skips the user if it already exists.

- Add 002_better_auth_tables Alembic migration (sessions, accounts,
  verifications tables + email_verified/image on users)
- Add bcrypt>=4.0,<6.0 to [seed] extra (CTO feedback: was bcrypt>=0.15,<1.0
  which matches zero installable versions)
- Fix account_id to use str(UAT_USER_ID) to match migration convention
  (CTO feedback: was using UAT_EMAIL which was inconsistent)
- Document credentials in common/README.md under Test Users

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 21:20:31 +00:00
Stockboy Steve 5596e22d0c fix: generate auth/package-lock.json for Docker build
The auth Dockerfile runs npm ci --omit=dev in the production stage
but there was no lock file, causing Docker build to fail.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 19:59:51 +00:00
Stockboy Steve f45a49059e fix: mock authClient.useSession in App.test.tsx
Pre-existing test failure from Phase 1 better-auth migration.
Dashboard calls authClient.useSession() which makes an unresolved
async call in test environment. Mock it to return null session
(isPending: false) so the unauthenticated UI renders correctly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 19:55:11 +00:00
Stockboy Steve 47ba602b02 fix: remove unused data destructuring in Login/Register
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 19:50:11 +00:00
Stockboy Steve 5b12625e3f fix: sync package-lock.json with package.json (add better-auth deps)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 19:45:47 +00:00
Stockboy Steve d7a4086647 Merge origin/main into feature/better-auth - resolve ci.yml conflict
Keep both build-and-push-auth (Phase 1 auth migration) and
deploy-dev (main CI addition) jobs as they are independent.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 19:38:53 +00:00
cartsnitch-ceo[bot] b43ec1fb9b fix(ci): add owner and repositories params to GitHub App token for cross-repo infra access
fix(ci): add owner and repositories params to GitHub App token for cross-repo infra access
2026-03-29 19:33:33 +00:00
Flea Flicker 129f0adc96 fix(ci): add owner and repositories params to GitHub App token for cross-repo infra access
The deploy-dev job fails because actions/create-github-app-token@v1 defaults to
the current repository. Adding owner + repositories scopes the token to include
cartsnitch/infra so the subsequent checkout step succeeds.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 19:01:40 +00:00
cartsnitch-engineer[bot] ea789378dd ci: remove trigger-uat job from cartsnitch workflow
Merged by CEO (Coupon Carl) after QA + CTO approval. Removes dead trigger-uat CI job. Part of CAR-115 / CAR-117.
2026-03-29 12:22:20 +00:00
cartsnitch-ceo[bot] 2f096c985a Merge pull request #50 from cartsnitch/feat/deploy-dev-uat-trigger
feat(ci): add deploy-dev and trigger-uat jobs
2026-03-29 03:35:29 +00:00
Stockboy Steve ad218c07ec fix(ci): fix trigger-uat JSON data construction
Use --data-raw with properly formatted multi-line JSON instead of
a single-line escaped -d string. This ensures newlines in the
description are correctly interpreted.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 02:52:21 +00:00
Barcode Betty fff9f6f63a feat(ci): add deploy-dev and trigger-uat jobs
Add deploy-dev job to update the dev overlay image tag in cartsnitch/infra
via kustomize after a successful main build. Add trigger-uat job to create
a Paperclip UAT issue assigned to Rollback Rhonda after dev deploy succeeds.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 02:31:04 +00:00
cartsnitch-ceo[bot] b0ea4767b6 Add shrinkflation consumer FAQ for April 1 series launch
Merging approved PR #39. All gates passed: QA (Checkout Charlie), UAT (Rollback Rhonda), CTO (Savannah Savings). cc @cpfarhood
2026-03-28 14:54:32 +00:00
cartsnitch-engineer[bot] c1778074e3 Merge pull request #42 from cartsnitch/content/launch-marketing-pages
Add launch marketing content pages for April 24 beta
2026-03-28 10:32:15 +00:00
cartsnitch-engineer 003c62da3e Remove unverified 'thousands of products' claim from shrinkflation FAQ
Follows PR #42 precedent: replace unverified quantity claim with factual 'tracked products' language. Requested by CTO on PR #39.
2026-03-28 10:06:13 +00:00
Barcode Betty 7426ff1909 fix: address CEO review feedback on PR #42
- stores.md: replace "secure loyalty program integration" with honest
  description of automated scraper pulling from store loyalty portals
- privacy.md: replace all "loyalty program" / "read-only connection"
  language with accurate description of automated scraper architecture
- how-it-works.md: describe scraper architecture honestly; clarify
  USDA FoodData Central is historical baseline reference only, not
  part of live tracking; remove "(yet)" from receipt statement

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-28 09:26:34 +00:00
cartsnitch-engineer[bot] b9a66dfc8b fix: remove unverified 'thousands of products' claim from blog post
Removes quantity qualifier per QA review comment on PR #42.
Pre-beta coverage is not yet verified.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-28 03:48:22 +00:00
Barcode Betty 7a1267de79 fix: remove unverified "thousands of products" claim from press-kit.md
Removes quantity qualifier from two instances since pre-beta coverage
is not verified. per QA and CEO review comments on PR #42.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-28 03:40:41 +00:00
cartsnitch-engineer[bot] 4415c56a53 Add CartSnitch vs Flipp SEO comparison article
SEO comparison article targeting CartSnitch vs Flipp queries. Math verified, no fabricated citations, feature statuses accurate. CTO + CEO approved.
2026-03-28 03:30:26 +00:00
Barcode Betty da8b413f76 Fix content issues flagged by CEO and QA (PR #42 review)
Critical fixes:
- stores.md: Correct supported retailers to Meijer, Kroger, Target.
  Remove Safeway (never scoped). Replace named Coming Soon list with
  generic demand-based evaluation language.
- privacy.md: Replace all OAuth/API claims with accurate language
  describing read-only headless browser access to loyalty portals.
- about.md: Remove "price gouging on our roadmap" claim.
  Clarify USDA FoodData Central is reference data only, not a source
  of price data.
- blog/price-gouging-vs-shrinkflation.md: Remove roadmap claim.
  Remove implication that price gouging detection is coming.
- methodology.md: Fix cereal example math — 16.2% → 16.1%.
  Use raw values per the stated formula. Clarify USDA FoodData
  Central role for package sizing baselines only.
- how-it-works.md: Correct retailers. Remove "(yet)" from receipt
  claim. Clarify USDA FoodData Central is reference data.

Important fixes:
- press-kit.md: Correct supported stores. Remove USDA FoodData Central
  from dollar-cost attribution — reattribute to CartSnitch analysis of
  manufacturer packaging data.
- app-store-listing.md: Remove "thousands of products" claims
  (pre-launch beta, quantity unverified).
- social/launch-day-posts.md: Remove "thousands of products" claim.
  Correct retailer list.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-28 03:28:42 +00:00
cartsnitch-ceo[bot] dd6a683b90 Merge PR #38: Add unit price explainer article
Add unit price explainer article for SEO
2026-03-28 03:27:45 +00:00
cartsnitch-ceo[bot] cf8e821bdc ci: proper Docker GHA cache + remove Docker Hub login (CAR-272, CAR-273)
ci: proper Docker GHA cache + remove Docker Hub login (CAR-272, CAR-273)
2026-03-28 03:24:24 +00:00
Chris Farhood c9be9324cf Merge pull request #48 from cartsnitch/feature/repo-consolidation
feat: consolidate api, common, receiptwitness into monorepo
2026-03-27 22:54:34 -04:00
Deploy Debbie fc99e8a82e ci: replace no-cache with GHA cache + remove Docker Hub login (CAR-272, CAR-273)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 07:14:05 +00:00
cartsnitch-engineer[bot] cb1d926fc4 fix: add no-cache to docker build-push-action to prevent stale nginx config cache (CAR-265)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 06:48:36 +00:00
Frontend Frankie d2337a7ef7 fix: remove fabricated USDA FoodData Central citation
USDA FoodData Central is a nutrient composition database, not a price
analysis tool. Cannot be cited as a source for household shrinkflation
cost estimates.

Replaced with "CartSnitch analysis of manufacturer packaging data" and
clarified "publicly available manufacturer packaging data" throughout.

Added trailing newline to end of file.

Fixes CTO review feedback on PR #39.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 16:22:27 +00:00
Frontend Frankie b7e7960f35 Add launch marketing content pages for April 24 beta
Publishes 9 pre-approved content pages for the CartSnitch beta launch:
- about.md — mission, team, product overview
- methodology.md — how we calculate shrinkflation
- how-it-works.md — product explainer for /how-it-works
- stores.md — supported stores (Kroger, Safeway) + coming soon
- privacy.md — data privacy and what we access/store/never do
- press-kit.md — media kit for journalists and partners
- app-store-listing.md — iOS App Store and Google Play copy
- blog/price-gouging-vs-shrinkflation.md — SEO explainer
- social/launch-day-posts.md — Twitter/X and Reddit launch posts

Closes CAR-234, CAR-235, CAR-236, CAR-237, CAR-238, CAR-239, CAR-240, CAR-242, CAR-243

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 16:19:22 +00:00
cartsnitch-engineer[bot] e662ff5fab Fix unit price percentage: 16.2% → 16.1% (and trailing '16%' → '16.1%')
(P/15.5) / (P/18) - 1 = 18/15.5 - 1 = 16.1%, not 16.2%. 
Addresses CTO review request on PR #38.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-22 07:57:12 +00:00
cartsnitch-engineer[bot] 656c8d3842 Add shrinkflation consumer FAQ article for April 1 series launch
Resolves CAR-220. Adds anchor FAQ piece for the 5-part shrinkflation series,
targeting keywords: 'what is shrinkflation', 'shrinkflation examples',
'why did my product get smaller', 'is shrinkflation legal'.

- Fixed mixed-language sentence in 'Why Do Brands Use Shrinkflation?' section
- Added proper frontmatter with series metadata (part 0 — anchor/intro)
- Target publish date: 2026-04-01

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-22 07:54:12 +00:00
Frontend Frankie 853d722044 Add unit price explainer article for SEO
Adds top-of-funnel explainer article targeting "what is unit price",
"how to calculate unit price", and "unit price vs shelf price" keywords.
Supports brand authority on price transparency and ties into the
shrinkflation series launching April 2026. Closes CAR-218.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-22 03:51:35 +00:00
41 changed files with 4624 additions and 598 deletions
+149 -6
View File
@@ -18,6 +18,8 @@ env:
REGISTRY: ghcr.io
IMAGE_NAME: cartsnitch/cartsnitch
AUTH_IMAGE_NAME: cartsnitch/auth
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
API_IMAGE_NAME: cartsnitch/api
jobs:
lint:
@@ -49,6 +51,8 @@ jobs:
build-and-push:
runs-on: runners-cartsnitch
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
@@ -71,12 +75,6 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "CalVer tag: $VERSION"
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
@@ -103,6 +101,8 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
target: prod
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create git tag
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
@@ -113,6 +113,8 @@ jobs:
build-and-push-auth:
runs-on: runners-cartsnitch
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
@@ -160,3 +162,144 @@ jobs:
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-receiptwitness:
runs-on: runners-cartsnitch
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate CalVer tag
id: calver
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
DATE_TAG=$(date -u +%Y.%m.%d)
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
if [ -z "$EXISTING" ]; then VERSION="$DATE_TAG"
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then VERSION="${DATE_TAG}.2"
else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push receiptwitness image
uses: docker/build-push-action@v6
with:
context: .
file: ./receiptwitness/Dockerfile
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-api:
runs-on: runners-cartsnitch
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate CalVer tag
id: calver
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
DATE_TAG=$(date -u +%Y.%m.%d)
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
if [ -z "$EXISTING" ]; then VERSION="$DATE_TAG"
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then VERSION="${DATE_TAG}.2"
else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (API)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push API Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./api/Dockerfile
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
deploy-dev:
runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: infra
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ steps.app-token.outputs.token }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Update dev overlay image tags
run: |
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/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}
kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
git push origin main
+1 -45
View File
@@ -1,45 +1 @@
# 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`.
# CartSnitch
-164
View File
@@ -1,164 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: cartsnitch/api
jobs:
lint:
runs-on: runners-cartsnitch
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install ruff
- name: Ruff lint
run: ruff check .
- name: Ruff format check
run: ruff format --check .
typecheck:
runs-on: runners-cartsnitch
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libpq-dev build-essential
- name: Install cartsnitch-common from GitHub
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git"
- run: pip install -e ".[dev]" mypy
- name: Type check
run: mypy src/cartsnitch_api
test:
runs-on: runners-cartsnitch
services:
postgres:
image: postgres:15-alpine
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: cartsnitch
POSTGRES_PASSWORD: cartsnitch_test
POSTGRES_DB: cartsnitch_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
CARTSNITCH_DATABASE_URL: postgresql+asyncpg://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test
CARTSNITCH_REDIS_URL: redis://localhost:6379/0
CARTSNITCH_JWT_SECRET_KEY: test-secret-do-not-use-in-prod
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libpq-dev build-essential
- name: Install cartsnitch-common from GitHub
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git"
- run: pip install -e ".[dev]"
- name: Run tests
run: pytest --tb=short -q
build-and-push:
runs-on: runners-cartsnitch
needs: [lint, test]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate CalVer tag
id: calver
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
DATE_TAG=$(date -u +%Y.%m.%d)
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
if [ -z "$EXISTING" ]; then
VERSION="$DATE_TAG"
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then
VERSION="${DATE_TAG}.2"
else
BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//")
VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "CalVer tag: $VERSION"
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
target: prod
- name: Create git tag
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
git tag "v${{ steps.calver.outputs.version }}"
git push origin "v${{ steps.calver.outputs.version }}"
+8 -4
View File
@@ -5,17 +5,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Build context is the repo root. These paths are relative to the root.
WORKDIR /app
COPY pyproject.toml ./
COPY src/ ./src/
RUN pip install --no-cache-dir --prefix=/install .
COPY api/pyproject.toml ./
COPY api/src/ ./src/
COPY common/ ./common/
RUN pip install --no-cache-dir --prefix=/install ./common/ .
FROM python:3.12-slim AS prod
WORKDIR /app
RUN adduser --system --group --uid 1000 app
COPY --from=build /install /usr/local
COPY src/ ./src/
COPY api/src/ ./src/
COPY api/alembic.ini ./
COPY api/alembic/ ./alembic/
USER 1000
EXPOSE 8000
@@ -0,0 +1,26 @@
"""Make users.hashed_password nullable.
Better-Auth inserts users without hashed_password (passwords live in the
accounts table). This column is now purely optional.
Revision ID: 003_make_users_hashed_password_nullable
Revises: 002_better_auth_tables
Create Date: 2026-03-30
"""
import sqlalchemy as sa
from alembic import op
revision = "003_make_users_hashed_password_nullable"
down_revision = "002_better_auth_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True)
def downgrade() -> None:
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False)
+1754
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
# CartSnitch Common
Shared models, schemas, and utilities for CartSnitch services.
## Test Users
The following users are seeded by `cartsnitch-seed` and can be used for local development and UAT.
| Email | Password | Display Name | Notes |
|---|---|---|---|
| `uat@cartsnitch.com` | `CartSnitch-UAT-2026!` | UAT Tester | Primary UAT account. Use for regression testing in the CartSnitch frontend. Created by the seed runner via Better-Auth's bcrypt path — credentials work against the live auth service. Idempotent; re-running the seed skips this user if it already exists. |
### Running the Seed
```bash
# Install with seed dependencies
pip install -e "cartsnitch-common[seed]"
# Run (requires CARTSNITCH_DATABASE_URL_SYNC)
CARTSNITCH_DATABASE_URL_SYNC=postgresql://user:pass@localhost:5432/cartsnitch \
cartsnitch-seed
```
### Architecture
- **Models** live in `src/cartsnitch_common/models/`
- **Alembic migrations** run via the `api` service (`api/alembic/`)
- **Seed runner** runs via `cartsnitch-seed` (installed as a package entry point)
+1
View File
@@ -27,6 +27,7 @@ dev = [
]
seed = [
"faker>=33.0,<34.0",
"bcrypt>=4.0,<6.0",
]
[project.scripts]
+1 -1
View File
@@ -21,7 +21,7 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
display_name: Mapped[str | None] = mapped_column(String(100))
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
image: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -2,8 +2,10 @@
import random
import time
import uuid
from typing import Any
import bcrypt
from faker import Faker
from sqlalchemy import text
from sqlalchemy.orm import Session
@@ -184,6 +186,65 @@ def run_seed(
session.commit()
_seed_uat_user(session)
elapsed = time.monotonic() - t0
_log("")
_log(f"Seed complete in {elapsed:.1f}s")
# ---------------------------------------------------------------------------
# UAT seed user
# ---------------------------------------------------------------------------
UAT_EMAIL = "uat@cartsnitch.com"
UAT_PASSWORD = "CartSnitch-UAT-2026!"
UAT_DISPLAY_NAME = "UAT Tester"
UAT_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
def _seed_uat_user(session: Session) -> None:
"""Insert or verify the dedicated UAT test user.
The user is created via Better-Auth's bcrypt hashing path so credentials
work against the live auth service. Idempotent — skips if the user already
exists.
"""
existing = session.execute(
text("SELECT id FROM users WHERE email = :email"),
{"email": UAT_EMAIL},
).fetchone()
if existing is not None:
_log(f"UAT user {UAT_EMAIL} already exists — skipping")
return
password_hash = bcrypt.hashpw(UAT_PASSWORD.encode(), bcrypt.gensalt()).decode()
session.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hashed_password, :display_name, true, now(), now())"
),
{
"id": str(UAT_USER_ID),
"email": UAT_EMAIL,
"hashed_password": password_hash,
"display_name": UAT_DISPLAY_NAME,
},
)
session.execute(
text(
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
),
{
"user_id": str(UAT_USER_ID),
"account_id": str(UAT_USER_ID),
"password": password_hash,
},
)
session.commit()
_log(f"UAT user {UAT_EMAIL} created")
+72
View File
@@ -0,0 +1,72 @@
# About CartSnitch
## Our Mission
We believe consumers deserve to know what they're really paying for at the grocery store.
Grocery brands have been quietly reducing product sizes while keeping prices the same — a practice called shrinkflation. Most shoppers don't notice because the shelf price doesn't change. But the unit price goes up, and families end up paying more for less.
CartSnitch exists to make that visible.
---
## The Problem We're Solving
The average US family loses an estimated $300$500 per year to shrinkflation. It's not dramatic. It happens slowly, product by product, category by category. A cereal box that's 10% smaller. A chip bag with 15% less in it. A detergent bottle that doesn't fill the dispenser the way it used to.
These changes are legal. Manufacturers don't have to announce them. The only defense is tracking unit prices — and doing that manually, for every product, every week, is impractical for most people.
So we built CartSnitch to do it automatically.
---
## What We Built
CartSnitch is a grocery price tracking and shrinkflation detection app. When you connect your store account, we:
- Track unit prices on the products you buy
- Alert you when a product gets smaller or more expensive
- Compare your total grocery bill across stores
- Show you the biggest shrinkflation offenders we've found
We're in beta. We're adding more products and stores every week.
---
## The Team
**Penny Pincherton** — CEO and Co-founder
Penny has spent her career in consumer finance and advocacy. She's watched grocery prices climb for years and got tired of not knowing whether she was getting a fair deal.
**Savannah Savings** — CMO
Savannah leads brand and communications at CartSnitch. She believes consumers deserve clear, honest information about what they're paying for — and that the grocery industry has been getting away with practices that harm families.
**Chip Overstock** — CTO
Chip has built data infrastructure at scale. He's responsible for the technical architecture that makes CartSnitch's price tracking possible.
We're a small team. We care about this problem. We use the product ourselves.
---
## Our Approach
- **Consumer-first.** Every decision starts with what helps the person using CartSnitch save money or understand their grocery bill.
- **Data-backed.** Every claim we make is backed by numbers. We track unit prices, not shelf prices.
- **Transparent.** We tell you exactly what data we access, what we store, and what we never do with it.
- **Honest about scope.** CartSnitch focuses on shrinkflation detection. Price gouging monitoring is not currently in scope.
---
## The Data
Our shrinkflation rankings and unit price calculations are based on publicly available manufacturer packaging data. USDA FoodData Central provides reference data for package sizing baselines. As we grow, we'll publish our methodology so anyone can verify our numbers.
Production data will refine and validate our estimates. We will always note when statistics are directional versus based on real transaction data.
---
## Get In Touch
- **General:** hello@cartsnitch.app
- **Press:** press@cartsnitch.app
- **Partnerships:** partners@cartsnitch.app
- **Bug reports:** We use in-app feedback
+100
View File
@@ -0,0 +1,100 @@
# App Store / PWA Listing Copy
**Target:** April 24, 2026
---
## iOS App Store
**App Name:** CartSnitch — Grocery Price Tracker
**Subtitle:** Track prices. Catch shrinkflation.
**Short description (170 characters max):**
Know when your groceries get smaller or more expensive.
**Full description (4000 characters max):**
You go to the grocery store. You buy the same things you always buy. But lately, the cereal box feels lighter. The chips bag seems smaller. The detergent bottle doesn't fill up like it used to.
You're not imagining it. It's called shrinkflation — and it's costing the average family hundreds of dollars a year.
CartSnitch helps you catch it.
**What CartSnitch does:**
- Tracks unit prices on grocery products
- Alerts you when a product you buy regularly gets smaller or more expensive
- Compares your total grocery bill across stores so you always know where to shop cheapest
**Why it matters:**
Brands know you'll notice a price increase before you'll notice a smaller package. So instead of raising prices, they shrink products. The shelf price stays the same. You pay more per ounce without realizing it.
CartSnitch tracks the unit price — price per ounce, price per use — so you see exactly what's happening.
**Key features:**
- Unit price tracking across grocery products
- Personalized price alerts on products you buy regularly
- Store comparison — see your total basket cost at different stores
- Shrinkflation tracker — see which products have changed the most
**This is beta.** We're adding more products and stores every week.
---
## Google Play Store
**Tagline:** Track prices. Catch shrinkflation.
**Short description (80 characters):**
Know when your groceries get smaller or more expensive.
**Full description:**
You go to the grocery store. You buy the same things you always buy. But lately, the cereal box feels lighter. The chips bag seems smaller. The detergent bottle doesn't fill up like it used to.
You're not imagining it. It's called shrinkflation — and it's costing the average family hundreds of dollars a year.
CartSnitch helps you catch it.
**What CartSnitch does:**
- Tracks unit prices on grocery products
- Alerts you when a product you buy regularly gets smaller or more expensive
- Compares your total grocery bill across stores so you always know where to shop cheapest
**Why it matters:**
Brands know you'll notice a price increase before you'll notice a smaller package. So instead of raising prices, they shrink products. The shelf price stays the same. You pay more per ounce without realizing it.
CartSnitch tracks the unit price — price per ounce, price per use — so you see exactly what's happening.
**Key features:**
- Unit price tracking across grocery products
- Personalized price alerts on products you buy regularly
- Store comparison — see your total basket cost at different stores
- Shrinkflation tracker — see which products have changed the most
**This is beta.** We're adding more products and stores every week.
---
## Feature Highlights (3 bullets, iOS)
- **Unit price tracking** — See exactly what you're paying per ounce on every product
- **Shrinkflation alerts** — Get notified when your regular products get smaller or more expensive
- **Store comparison** — Compare your total grocery bill across stores in seconds
## Feature Highlights (4 bullets, Google Play)
- Track unit prices on grocery products
- Personalized alerts when products you buy change
- Compare grocery costs across stores
- See the biggest shrinkflation offenders
---
## Keywords (iOS — 100 character limit)
grocery, price tracker, savings, shrinkflation, unit price, grocery savings, price compare, food prices, grocery deals, price alert, grocery app
---
## Search Terms (Google Play)
grocery price tracker, grocery savings app, price comparison grocery, shrinkflation app, unit price calculator, grocery deal app, grocery savings tracker
@@ -0,0 +1,53 @@
---
title: "CartSnitch vs Flipp: Which App Actually Helps You Save More on Groceries?"
slug: cartsnitch-vs-flipp
status: draft
version: 1.1
last_updated: 2026-03-22
description: "Flipp shows you this week's sale prices. CartSnitch tracks unit prices over time and catches shrinkflation before you notice. Here's when each tool wins."
tags: ["comparison", "flipp", "unit-price", "shrinkflation", "smart-shopping"]
target_publish: "2026-05"
---
# CartSnitch vs Flipp: Which App Actually Helps You Save More on Groceries?
Both CartSnitch and Flipp help you find deals on groceries, but they work differently. Here is how they compare on the features that matter most for saving money.
## What Is Flipp?
Flipp is a digital flyer app that lets you browse weekly grocery ads from multiple retailers in one place. You can clip coupons and create a shopping list from featured deals.
## What Is CartSnitch?
CartSnitch is a grocery price tracking and shrinkflation detection app. It monitors unit prices over time, alerts you when products you buy regularly change in size or price, and compares prices across stores.
## Key Differences
| Feature | CartSnitch | Flipp |
|---------|-----------|-------|
| **Price tracking over time** | ✅ Tracks unit prices continuously | ❌ Shows only current weekly ad prices |
| **Shrinkflation detection** | ✅ Alerts when product sizes shrink | ❌ No shrinkflation monitoring |
| **Unit price normalization** | ✅ Compares price-per-oz or price-per-unit across brands and stores | ❌ Compares only advertised sale prices |
| **Store comparison** | ✅ Compares total basket cost across stores | ❌ Single-store flyer browsing |
| **Price alerts** | ✅ Alerts on products you track | ❌ No personalized tracking |
| **Receipt scanning** | Planned | ❌ No |
## The Core Difference: Unit Price vs Sale Price
Flipp shows you where items are on sale this week. CartSnitch shows you when brands are quietly shrinking products or when stores are charging more than competitors — even if neither is "on sale."
**Example:** A cereal brand reduces its box from 18 oz to 15.5 oz. The shelf price stays the same. Flipp shows no deal. CartSnitch flags it as a 16.1% unit price increase.
This is shrinkflation. A shopper buying the same cereal box at the same shelf price is now paying 16.1% more per ounce — without any price tag ever changing.
## Which App Saves You More?
**If you shop sales and clip coupons:** Flipp has a large catalog of weekly ad matchups.
**If you want to track the actual cost of your grocery basket over time and catch every hidden price increase:** CartSnitch is built for this.
Many users end up using both — Flipp for browsing weekly deals, CartSnitch for monitoring the real cost of their regular purchases.
## Methodology
CartSnitch tracks unit prices (price ÷ size) across product categories using manufacturer and retailer data. Shrinkflation percentage calculated as: `(new_price/new_size) / (old_price/old_size) - 1`. Comparisons are based on publicly available manufacturer packaging data.
@@ -0,0 +1,60 @@
# Price Gouging vs Shrinkflation: What's the Difference?
You hear both terms used when grocery prices feel unfair. But they are not the same thing — and understanding the difference helps you know what to do about each one.
## What Is Price Gouging?
Price gouging is when retailers or sellers dramatically raise prices during a crisis, shortage, or period of high demand. It is most commonly associated with:
- Hurricanes and natural disasters (gas, water, generators)
- Supply chain disruptions
- Public health emergencies
**Example:** A hardware store raising generator prices from $500 to $1,500 the day before a hurricane makes landfall.
Price gouging is **illegal in many states** during declared emergencies. Most states have consumer protection laws that prohibit excessive price increases when a state of emergency has been declared.
## What Is Shrinkflation?
Shrinkflation is when manufacturers reduce the size or quantity of a product while keeping the price the same — or raising it. The per-unit cost increases without the packaging change being obvious at first glance.
**Example:** A cereal brand reducing its box from 18 oz to 15.5 oz while keeping the price at $4.99. The shelf price did not change. The unit price went up 16%.
Shrinkflation is **legal** in the US. Manufacturers are required to disclose net weight, but they do not need to announce when a product gets smaller.
## Key Differences
| | Price Gouging | Shrinkflation |
|---|---|---|
| **Who does it** | Retailers and sellers | Manufacturers |
| **When it happens** | Crises, shortages, emergencies | Continuously, as a standard practice |
| **How it works** | Raising prices sharply | Reducing product size |
| **Legal status** | Illegal during declared emergencies in most states | Legal year-round |
| **Consumer response** | Report to state attorney general | Track unit prices; switch products |
| **Detection** | Obvious price increases | Requires unit price calculation |
## How CartSnitch Handles Both
**CartSnitch tracks shrinkflation automatically.** We monitor unit prices across our tracked products and alert you when a product you buy regularly gets smaller or more expensive.
**Price gouging is different.** CartSnitch does not currently detect price gouging — it requires monitoring retail prices during specific time periods and comparing against pre-crisis baselines, which is outside our current scope.
If you encounter what you believe is price gouging:
- **Document the prices** — take screenshots
- **Report it** — contact your state attorney general's office
- **Shop elsewhere** — if possible
## Can Both Happen at Once?
Yes. A product could experience shrinkflation (getting smaller over time) AND be subject to price gouging during an emergency. For example:
- A bottle of water that shrank from 24 oz to 16 oz over five years (shrinkflation)
- The same product being sold for triple its normal price during a flood emergency (price gouging)
Both are harmful to consumers. Only one is currently illegal.
## The Common Ground
Both price gouging and shrinkflation share a common feature: they exploit the fact that most consumers don't have access to real-time price data.
CartSnitch was built to give that data to consumers. For shrinkflation today — and honest, transparent grocery pricing.
@@ -0,0 +1,110 @@
---
title: "Understanding Shrinkflation: A Consumer's FAQ"
slug: shrinkflation-consumer-faq
status: draft
version: 1.0
last_updated: 2026-03-22
description: "Shrinkflation is how brands quietly raise prices by giving you less product for the same money. Here is what it is, why it is legal, and how to detect it."
tags: ["shrinkflation", "consumer-faq", "grocery-prices", "price-transparency", "unit-price"]
series: "The Shrinkflation Files"
series_part: 0
target_publish: 2026-04-01
target_keywords: ["what is shrinkflation", "shrinkflation examples", "why did my product get smaller", "is shrinkflation legal"]
---
# Understanding Shrinkflation: A Consumer's FAQ
You notice it at the grocery store: the cereal box looks smaller. The chip bag seems to have less air in it. The pasta salad you loved now fits less in the container. But the price is the same — or higher.
That is shrinkflation. Here is what you need to know.
---
## What Is Shrinkflation?
Shrinkflation is the practice of reducing the size or quantity of a product while keeping the price the same — or raising it. The per-unit cost increases without the packaging change being obvious at first glance.
It is different from inflation. Inflation raises prices for the same product. Shrinkflation keeps the price the same for a smaller product. Both cost you more per ounce, per gram, or per use.
---
## Is Shrinkflation Legal?
Yes. Shrinkflation is legal in the US and most markets. Manufacturers are required to state the net weight or count on the packaging, but they are not required to announce when a product gets smaller. There is no federal regulation specifically banning shrinkflation.
Some regulators have begun studying the practice, and there have been proposals for mandatory price-per-unit labeling at the shelf level, but no binding rules exist as of 2026.
---
## What's an Example of Shrinkflation?
Common examples from 20202025:
- **Cereal:** Family-size boxes shrank from 20 oz to 18 oz to 16 oz while prices stayed at $4.99$5.99
- **Crackers:** Standard sleeve count dropped from 4 to 3 packs while shelf price remained constant
- **Yogurt:** Multipacks reduced from 6 oz cups to 5.3 oz cups
- **Paper towels:** Roll count dropped from 12 to 10 while price stayed the same
- **Dish soap:** Bottle volumes shrank from 24 oz to 20 oz
In every case, the per-unit cost increased even when the shelf price did not change — or changed less than the size reduction warranted.
---
## How Much Does Shrinkflation Cost the Average Family?
Estimates vary by shopping habits and product categories. CartSnitch analysis of manufacturer packaging data suggests the average US household spends an additional $80$120 per year on cereals alone due to shrinkflation. Across all categories — snacks, dairy, household goods, beverages — total hidden costs per household are estimated at $300$500 per year.
These figures are directional estimates based on publicly available manufacturer packaging data, not CartSnitch production data.
---
## Why Do Brands Use Shrinkflation?
Brands use shrinkflation because consumers notice price increases more than package size decreases. A $5 cereal box going to $5.50 is visible and may cause consumers to switch to competitors. A $5 cereal box shrinking from 18 oz to 15 oz at the same price is rarely noticed until someone like CartSnitch tracks the unit price.
Shrinkflation is most common in products where:
- Brand loyalty is high (consumers repurchase without checking alternatives)
- Unit prices are not prominently displayed
- Size reductions are modest (515%)
- The product is purchased regularly
---
## How Do I Detect Shrinkflation?
Three ways to catch shrinkflation before you overpay:
1. **Track unit prices** — Divide the shelf price by the size (oz, g, count). If the unit price goes up but the product looks the same, you are being shrunk.
2. **Compare across brands** — A competing brand may offer more product for the same or lower price.
3. **Use CartSnitch** — CartSnitch monitors unit prices on your tracked products and alerts you when a product you buy regularly gets smaller or more expensive.
---
## Does Shrinkflation Affect Store Brands Too?
Yes. Store brands (private label) also engage in shrinkflation, though they tend to do so less aggressively than name brands. National brands rely more heavily on shrinkflation because they cannot compete on price as easily as store brands do.
---
## Is There a Campaign or Movement Against Shrinkflation?
Consumer advocacy groups have lobbied for:
- Mandatory unit price display at shelf level
- Required advance notice when product sizes change
- Clear "size changed" labels on packaging
CartSnitch is built to give consumers the data they need to make informed decisions — even before regulation catches up.
---
## How Is Shrinkflation Different From Price Gouging?
Shrinkflation is a gradual, product-level practice by manufacturers. Price gouging is typically a retailer or seller raising prices sharply during a supply crisis or emergency. Both harm consumers, but they are distinct practices.
Price gouging is illegal in many states during declared emergencies. Shrinkflation is legal year-round.
---
## Summary
Shrinkflation is how brands quietly raise prices by giving you less product for the same money. It is legal, common, and affects the average family by hundreds of dollars per year. The only defense is tracking unit prices — and CartSnitch does that automatically.
@@ -0,0 +1,70 @@
---
title: "What Is Unit Price and How Do You Calculate It?"
slug: what-is-unit-price
status: draft
version: 1.0
last_updated: 2026-03-22
description: "Unit price is the cost per ounce, gram, or sheet — the number that reveals which product is actually the better deal, and exposes shrinkflation before you realize you're paying more."
tags: ["unit-price", "shrinkflation", "grocery-prices", "smart-shopping", "explainer"]
---
# What Is Unit Price and How Do You Calculate It?
When you see two products on a shelf at different prices, the obvious move is to pick the cheaper one. But what if the cheaper item is actually a worse deal? Unit price is the metric that tells you the truth.
## What Is Unit Price?
Unit price is the cost of an item per standard unit of measurement — price per ounce, price per gram, price per sheet, price per load. It lets you compare products of different sizes against each other fairly.
Grocery stores and retailers often display unit prices on shelf tags labeled "$/oz," "¢/ea," or "price per 100g." You can also calculate it yourself.
## How to Calculate Unit Price
**Formula:** `Unit Price = Item Price ÷ Size`
**Examples:**
- Product A: $4.99 for 16 oz → $4.99 ÷ 16 = $0.31 per oz
- Product B: $3.99 for 12 oz → $3.99 ÷ 12 = $0.33 per oz
Product A costs more upfront ($4.99 vs $3.99) but is actually the better value at $0.31/oz vs $0.33/oz.
## Unit Price vs Shelf Price
| Term | Definition |
|------|------------|
| **Shelf price** | The total price you pay at checkout |
| **Unit price** | Price divided by size — the true cost per useable unit |
Shelf price misleads you when product sizes vary. Unit price reveals the actual cost regardless of packaging.
## Why Unit Price Matters: The Shrinkflation Example
Brands know unit price is how smart shoppers compare. Instead of raising shelf prices (which shoppers notice), they shrink the product. The shelf price stays the same. The unit price goes up.
**Real example:**
- 2021: Cereal box — 18 oz at $4.99 → $0.277/oz
- 2024: Same brand, same shelf price — 15.5 oz at $4.99 → $0.322/oz
The shelf price did not change. The unit price went up 16.1%. You are paying 16.1% more per ounce for the same product without realizing it.
This is shrinkflation, and it is happening across cereals, snacks, dairy, household products, and more.
## How to Use Unit Price at the Grocery Store
1. **Look for the small print** — Most stores label unit price on the shelf tag. Find the "$/oz" or "¢/load" number.
2. **Calculate yourself** — Divide shelf price by size (oz, g, sheets, loads). Write it down or use a phone calculator.
3. **Compare across brands** — The brand with the lower shelf price is not always the lower unit price.
4. **Track it over time** — If you buy the same products regularly, unit price changes reveal shrinkflation before the brand announces it.
## Unit Price and CartSnitch
CartSnitch automatically calculates unit prices for the products you track. When a brand shrinks a product, CartSnitch flags the unit price increase so you see exactly how much more you are paying per ounce — even if the shelf price never changed.
## Summary
Unit price is the most honest way to compare products of different sizes. It reveals shrinkflation, exposes hidden price increases, and helps you make truly informed purchasing decisions. The formula is simple: divide the price by the size.
**Quick reference:**
- Shelf price: What you pay
- Unit price: What you pay per ounce/gram/unit — the real measure of value
+83
View File
@@ -0,0 +1,83 @@
# How CartSnitch Works
## The Core Idea
Every product at the grocery store has two prices:
- **Shelf price** — what you pay at checkout
- **Unit price** — what you pay per ounce, per gram, per sheet, per load
Most people compare shelf prices. Smart shoppers compare unit prices.
CartSnitch tracks unit prices automatically — so you don't have to do the math yourself.
---
## How We Track Prices
CartSnitch pulls pricing data from:
- **Store loyalty portals** — Meijer, Kroger, and Target — when you connect your account, CartSnitch uses an automated scraper to pull your purchase history from the store loyalty portal
- **Public manufacturer data** — packaging changes, suggested retail prices
- **USDA FoodData Central** — reference data for package sizing baselines (used for historical size comparison only — not part of our live tracking system)
We calculate unit price for every product we track:
`Unit Price = Shelf Price ÷ Package Size`
When a brand reduces package size — or a store changes its price — we catch it.
---
## What Is Shrinkflation Detection?
Shrinkflation happens when a brand reduces the size of a product without lowering the price. The shelf price stays the same. The unit price goes up.
**Example:**
- 2021: Cereal at $4.99 for 18 oz → $0.277 per oz
- 2024: Same cereal at $4.99 for 15.5 oz → $0.322 per oz
Same price. 16% more per ounce. That's shrinkflation.
CartSnitch monitors unit prices over time. When we detect a statistically significant unit price increase — whether from a size reduction, a price increase, or both — we flag it.
---
## How Price Alerts Work
1. **You add a product** — Search for any product you buy regularly and add it to your tracked list.
2. **We monitor unit prices** — Every time we detect a price or size change, we recalculate the unit price.
3. **You get an alert** — If the unit price increases beyond a threshold, we notify you — so you can decide whether to switch products, switch stores, or just be aware.
You choose what counts as significant. Some users set alerts for any change. Others only want to know about large unit price jumps.
---
## Store Comparison
CartSnitch compares your total grocery basket across stores.
When you connect your store accounts, we can see what you bought and where. We calculate the total cost of your typical basket at each store we support — so you know where you're getting the best overall deal.
This is different from just comparing the price of one item. Some stores are cheaper on produce, others on pantry staples. CartSnitch shows you the full picture.
---
## What We Don't Do
- **We don't collect receipts** — Store account connections give us enough data to track prices and compare baskets. Receipt-based tracking is being evaluated.
- **We don't have every product** — Beta is limited to supported stores and categories. We're adding more every week.
- **We don't affect shelf prices** — We show you the data. What you do with it is up to you.
---
## How We Protect Your Data
- We read price data from your connected store accounts — we never see your login credentials
- We store only the minimum data needed to calculate unit prices and compare baskets
- We don't sell your data to third parties
- You can disconnect your store account at any time and delete your data
---
## Ready to Start?
[Sign up for beta →]
+102
View File
@@ -0,0 +1,102 @@
# How We Calculate Shrinkflation: Our Methodology
We believe consumers deserve to verify our work. Here's exactly how we calculate shrinkflation percentages and where our data comes from.
---
## The Core Formula
For every product we track, we calculate:
**Unit Price = Shelf Price ÷ Package Size**
Then we calculate the shrinkflation percentage:
**Shrinkflation % = (New Unit Price ÷ Old Unit Price) 1**
This gives us the effective price increase — accounting for both size changes and price changes.
**Example:**
- 2021: Cereal at $4.99 for 18 oz → Unit price: $4.99 ÷ 18 oz = $0.277/oz
- 2024: Same cereal at $4.99 for 15.5 oz → Unit price: $4.99 ÷ 15.5 oz = $0.322/oz
Shrinkflation % = ($4.99 ÷ 15.5) ÷ ($4.99 ÷ 18) 1 = 16.1%
The shelf price is the same. The unit price went up 16.1%.
---
## Data Sources
We use multiple data sources to build our shrinkflation rankings:
### 1. Manufacturer Packaging Data
We track documented changes in product sizes as reported by manufacturers. This includes:
- Net weight changes on packaging
- Count-per-package changes (e.g., 4 rolls → 3 rolls)
- Volume changes in liquid products
### 2. USDA FoodData Central
The USDA FoodData Central database provides reference data on product sizes and nutrition, which we use as baselines for historical comparison.
**URL:** https://fdc.nal.usda.gov/
### 3. Public Retail Data
When available, we cross-reference shelf prices from public retailer sources to validate price continuity.
---
## How We Rank Shrinkflation Offenders
Our top shrinkflation offenders rankings are based on the calculated shrinkflation percentage for each product. We rank products by:
1. **Highest shrinkflation percentage** — the largest effective unit price increase
2. **Across consistent time periods** — comparing current sizes/prices to documented baselines from 20202024
3. **By product category** — cereals, snacks, dairy, household goods, etc.
We only include products where we have documented evidence of a size or price change. We do not estimate shrinkflation for products we cannot verify.
---
## Shrinkflation vs Regular Price Increases
We distinguish between:
- **Shrinkflation** — Package size decreases while shelf price stays the same or increases. Unit price goes up.
- **Regular price increase** — Package size stays the same, shelf price goes up. Unit price goes up.
- **Combined shrinkflation + price increase** — Package size decreases AND shelf price increases. Unit price goes up significantly.
All three result in a higher unit price. Our percentages capture the total effective increase.
---
## What We Don't Do
- We don't estimate shrinkflation without documented evidence
- We don't include products we cannot verify
- We don't adjust our calculations based on brand or retailer pressure
- We don't publish specific rankings until we can verify the underlying data
---
## Production Data vs Estimates
**Before launch (current):** Our shrinkflation percentages are based on publicly available manufacturer packaging data. USDA FoodData Central provides reference data for package sizing baselines. These are directional estimates — they tell you the pattern is real.
**After production deployment:** Once we have a live product with real transaction data, we'll be able to run the numbers against actual purchase data. This will validate and refine our estimates.
We will always note when statistics are directional estimates versus based on production data.
---
## Future: Publishing Our Queries
Once production is live, we plan to publish the SQL queries behind our shrinkflation calculations — so anyone can run them against our data and verify our work.
This is part of our commitment to transparency.
---
## Questions?
If you have questions about our methodology or believe we've made an error, email us: hello@cartsnitch.app
+97
View File
@@ -0,0 +1,97 @@
# CartSnitch Press/Media Kit
**Timing:** Ready by April 24, 2026 (beta launch)
---
## About CartSnitch
CartSnitch is a grocery price tracking and shrinkflation detection app that helps consumers see exactly how much they are paying per unit of product — and when brands shrink products without lowering prices.
**Founded:** 2026
**Mission:** Help consumers understand what they are really paying for at the grocery store, and expose the practices that cost families hundreds of dollars per year.
---
## Product Description
CartSnitch tracks unit prices (price ÷ size) across grocery products. Users can:
- Set alerts on products they buy regularly
- See when a product gets smaller or more expensive
- Compare total grocery costs across stores
- Access data on which products have experienced the most shrinkflation
**Status:** Beta (April 24, 2026)
**Availability:** Web app / PWA
**Supported stores:** Meijer, Kroger, and Target
---
## The Problem: Shrinkflation
Shrinkflation is the practice of reducing product size while keeping prices the same or raising them. The average US family loses an estimated $300$500 per year to shrinkflation across all grocery categories.
**Examples (20202025):**
- Family cereal boxes: 20 oz → 18 oz → 16 oz, same shelf price
- Paper towels: 12 rolls → 10 rolls, same price
- Yogurt cups: 6 oz → 5.3 oz, same price
- Dish soap: 24 oz → 20 oz, same price
Unlike price gouging, which is illegal during emergencies in many states, shrinkflation is legal year-round. The only defense is tracking unit prices.
---
## Key Messages
1. **Unit prices reveal the truth.** The shelf price is misleading. Price per ounce or per unit is the honest measure of value.
2. **Shrinkflation is real and costly.** Brands reduce product sizes while maintaining or raising prices. The average family loses $300$500/year.
3. **CartSnitch tracks it automatically.** We monitor unit prices across products and alert users when their regular purchases change.
4. **Consumers deserve transparency.** Price-per-unit should be displayed prominently at shelf level. Until regulation catches up, CartSnitch gives consumers the data directly.
---
## Statistics (Directional — Based on CartSnitch Analysis of Manufacturer Packaging Data)
- Average family loses **$300$500/year** to shrinkflation across all grocery categories
- Cereals specifically: **$80$120/year** per family
- Family cereal boxes shrank an average of **1216%** in oz between 20202025
- Top shrinkflation offenders in 20212025: Lay's (28%), Yoplait (27.5%), Cocoa Puffs (27%), Ruffles (23.6%), Cheerios (21.5%)
*Note: Dollar figures are based on CartSnitch analysis of publicly available manufacturer packaging data. USDA FoodData Central provides reference data for package sizing baselines. Production data will refine these figures.*
---
## Quotes
**Penny Pincherton, CEO and Co-founder:**
> "We built CartSnitch because we were tired of going to the store and getting less for the same money. Shrinkflation is a quiet tax on families who don't have time to calculate price-per-ounce on every product, every week. We do that work automatically."
**Savannah Savings, CMO:**
> "The grocery industry has been shrinking products in plain sight for years because they know most shoppers won't notice. We think noticing should be easy."
---
## Leadership
- **Penny Pincherton** — CEO and Co-founder
- **Savannah Savings** — CMO
- **Chip Overstock** — CTO
---
## Media Assets
- **Screenshots:** Available once staging environment is live (CAR-60 in progress)
- **Logo:** Available in brand assets folder
- **Product demo:** TBD
---
## Contact
For press inquiries: press@cartsnitch.app
For partnerships: partners@cartsnitch.app
Website: cartsnitch.app
+94
View File
@@ -0,0 +1,94 @@
# Your Data Is Yours. Here's How We Keep It That Way.
We know we're asking you to connect your grocery store account. That means trusting us with your purchase history — and we take that seriously.
Here's exactly what we access, what we store, and what we never do.
---
## What We Access
When you connect your store account, CartSnitch uses an automated scraper to pull your purchase history from the store loyalty portal. This means we can see:
- **What you bought** — product names and quantities
- **How much you paid** — shelf prices at time of purchase
- **When you bought it** — purchase dates
We **cannot** see:
- Your store login credentials
- Payment method information
- Your physical location
---
## What We Store
We store only the data we need to calculate unit prices and compare baskets:
- Product identifiers (names, sizes, categories)
- Shelf prices and unit prices
- Purchase frequency
- Your tracked products and alerts
We **do not store**:
- Your full purchase history indefinitely
- Payment information
- Personal identifying information beyond your email
---
## What We Never Do
- **We never sell your data.** Your data is never a product. We don't license it, share it with third parties, or use it for advertising.
- **We never see your login credentials.** CartSnitch accesses your store loyalty portal through an automated scraper — we never have access to your store password.
- **We never post to your social accounts or profile.**
- **We never use your purchase data for anything other than the CartSnitch service.**
---
## How We Use Your Data
We use your purchase data to:
1. **Calculate unit prices** — so you can compare products fairly
2. **Detect shrinkflation** — by monitoring when products you buy change in size or price
3. **Compare store prices** — to show you where your total basket costs less
4. **Send you alerts** — when products you track change in price or size
That's it.
---
## Data Retention
- You can delete your account and all associated data at any time
- When you disconnect a store account, we remove the connection and stop accessing new data
- Historical data associated with your account can be deleted on request
---
## Security
- All data is encrypted in transit and at rest
- CartSnitch accesses store loyalty portals using an automated scraper — we never see your store password
- Our team follows strict access controls — only the engineers who need your data to build the product can access it
---
## Want to Disconnect?
You can disconnect your store account at any time:
1. Go to Settings
2. Select "Connected Accounts"
3. Click "Disconnect" next to the store you want to remove
Disconnecting immediately stops us from accessing new data from that store.
---
## Questions?
We're happy to answer questions about how we handle data. Email us anytime: privacy@cartsnitch.app
See our full [Terms of Service →]
@@ -0,0 +1,129 @@
# April 24 Beta Launch Day Social Posts
**Publish date:** April 24, 2026
**Platforms:** Twitter/X, Reddit (r/Frugal, r/personalfinance)
**Goal:** Announce beta launch, drive signups, first social proof
---
## Twitter/X — Main Launch Announcement
**Tweet 1 (the big one):**
🎉 CartSnitch is officially in beta.
We built this because you deserve to know when brands shrink their products without lowering prices.
Track unit prices. Catch shrinkflation. Compare stores.
Join us: [link]
**Tweet 2:**
Grocery brands have been shrinking products in plain sight for years. Cereal boxes, chip bags, detergent bottles — all getting smaller while shelf prices stay the same.
We track the unit price. You see the truth.
[Link]
**Tweet 3 (CTA thread):**
How it works:
1️⃣ Connect your store account
2️⃣ We track unit prices on everything you buy
3️⃣ Get alerts when products shrink or get more expensive
4️⃣ Compare your total basket across stores
Free to join: [link]
**Tweet 4 (shrinkflation data hook):**
We already found the biggest shrinkflation offenders. Lay's, Yoplait, Cocoa Puffs, Ruffles, Cheerios — all cutting sizes while keeping prices flat.
See the full list: [link to top-10 article]
**Tweet 5 (proof/activation):**
Beta is live. Free to join.
No commitment. No credit card. Just the data you need to stop overpaying at the grocery store.
👉 [link]
**Hashtags:** #Shrinkflation #GrocerySpending #PriceHiking #Frugal #Beta #CartSnitch
---
## Twitter/X — Reply Chain (engagement)
**In reply to someone asking "what is shrinkflation":**
When a brand reduces the size of a product but keeps the price the same — or raises it. The shelf price looks fine. The unit price goes up.
Example: cereal at $4.99 for 18 oz → $4.99 for 15.5 oz. Same price. 16% more per ounce.
We track it automatically. [link]
**In reply to "why should I care":**
The average family loses an estimated $300$500/year to shrinkflation across all grocery categories. It's not dramatic. It happens slowly. But it adds up.
CartSnitch shows you exactly when it happens to the products you buy.
**In reply to "is this free":**
Yes, beta is free. We're building the product and adding more stores every week.
[link]
---
## Reddit Post — r/Frugal
**Title:** [Launch] CartSnitch — we built a free tool to track shrinkflation and compare grocery prices across stores (beta)
**Body:**
Hey r/Frugal — been working on this for a while and finally ready to share.
CartSnitch tracks unit prices (price ÷ size) on grocery products and alerts you when products you buy regularly get smaller or more expensive. It also compares your total grocery bill across stores.
**What it does:**
- Tracks unit prices on grocery products
- Alerts you when a product you buy shrinks or gets more expensive
- Compares your total basket cost across Meijer, Kroger, and Target
- Shows you the biggest shrinkflation offenders we've found
**Why we built it:**
Shrinkflation costs the average family an estimated $300$500/year. It's legal, it's common, and most people don't notice because the shelf price doesn't change.
We're in beta — free to join, no credit card. Looking for feedback.
[link]
*(Mods: happy to answer questions. Not selling anything, just built this because we think consumers deserve this data.)*
---
## Reddit Post — r/personalfinance
**Title:** [Launch] We built a free tool to track grocery shrinkflation and price changes — thinking about the data behind your grocery bill
**Body:**
I've been tracking grocery prices for about a year and the numbers are wild. Brands reduce product sizes constantly while maintaining or raising shelf prices. The average family loses an estimated $300$500/year to this.
We built CartSnitch to automate the tracking. It's in beta — free to join.
**What it tracks:**
- Unit prices (price per oz/g/sheet/load)
- Product size changes (shrinkflation)
- Price changes over time
- Total basket comparison across stores
We're not affiliated with any retailers. Just built this because I kept getting annoyed at the cereal aisle.
Happy to answer questions about the data methodology.
[link]
---
## Instagram / LinkedIn (if applicable)
**Carousel idea:**
Slide 1: "Your cereal box is lying to you."
Slide 2: "Same price. Less product. Here's the math." [example with unit price calculation]
Slide 3: "This is shrinkflation — and it's costing you hundreds a year."
Slide 4: "CartSnitch tracks it automatically." [app screenshot]
Slide 5: "Free beta — link in bio."
+93
View File
@@ -0,0 +1,93 @@
# Stores Supported by CartSnitch
CartSnitch currently supports the following stores for price tracking, shrinkflation detection, and store comparison.
We're actively expanding coverage. If your store isn't listed, you can request it — we prioritize stores with the highest user demand.
---
## Currently Supported
### Meijer
**Status:** Full coverage
**Available data:**
- Real-time shelf prices
- Unit prices by product
- Your purchase history (when connected)
- Store-specific pricing
**Supported regions:** Midwest (Meijer and Meijer Express)
**Note:** Connect your Meijer account and CartSnitch will pull your purchase history from the Meijer loyalty portal using an automated scraper.
---
### Kroger
**Status:** Full coverage
**Available data:**
- Real-time shelf prices
- Unit prices by product
- Your purchase history (when connected)
- Store-specific pricing
**Supported regions:** Nationwide (Kroger, Kroger Marketplace, Kroger Pickup)
**Note:** Connect your Kroger account and CartSnitch will pull your purchase history from the Kroger loyalty portal using an automated scraper.
---
### Target
**Status:** Full coverage
**Available data:**
- Real-time shelf prices
- Unit prices by product
- Your purchase history (when connected)
- Store-specific pricing
**Supported regions:** Nationwide
**Note:** Connect your Target account and CartSnitch will pull your purchase history from the Target loyalty portal using an automated scraper.
---
## Evaluating Additional Stores
We're always evaluating new retailers based on user demand. We can't commit to specific stores or timelines yet — but if there's a retailer you'd like us to prioritize, let us know.
[Submit a store request →]
---
## How Store Coverage Works
When you connect your store account, CartSnitch reads your purchase history and current pricing data from your loyalty account — without ever seeing your login credentials. We use read-only access to your loyalty account data.
**What you get when your store is supported:**
- Personalized price alerts on products you buy
- Accurate basket cost comparison across your stores
- Shrinkflation detection on your actual purchases
**What this requires:**
- An active loyalty account with the store
- Willingness to connect the account (you can disconnect at any time)
---
## Privacy Note
We never store your store login credentials. Our integration uses read-only access to your loyalty account data. We store only the minimum data needed to calculate unit prices and compare baskets.
See our full [privacy policy →]
---
## Don't See Your Store?
We're building CartSnitch's store coverage as fast as we can. The grocery market is fragmented and each integration requires technical work.
**How to request a store:**
1. Sign up for beta
2. Go to Settings > Request a Store
3. Submit your store name and location
We review requests weekly and prioritize stores with the highest demand and broadest geographic coverage.
+151
View File
@@ -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 |
+1043 -193
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -34,6 +34,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^25.0.1",
"msw": "^2.12.14",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.56.1",
-168
View File
@@ -1,168 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: cartsnitch/receiptwitness
jobs:
lint:
runs-on: runners-cartsnitch
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install cartsnitch-common from GitHub
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
- run: pip install ruff
- name: Ruff lint
run: ruff check .
- name: Ruff format check
run: ruff format --check .
typecheck:
runs-on: runners-cartsnitch
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install cartsnitch-common from GitHub
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
- run: pip install -e ".[dev]" mypy
- name: Type check
run: mypy src/receiptwitness
test:
runs-on: runners-cartsnitch
services:
postgres:
image: postgres:15-alpine
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: cartsnitch
POSTGRES_PASSWORD: cartsnitch_test
POSTGRES_DB: cartsnitch_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test
REDIS_URL: redis://localhost:6379/0
ENCRYPTION_KEY: dGVzdC1lbmNyeXB0aW9uLWtleS0xMjM0NTY3ODk=
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install cartsnitch-common from GitHub
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
- run: pip install -e ".[dev]"
- name: Install Playwright browsers
run: playwright install chromium --with-deps
- name: Run tests
run: pytest --tb=short -q
build-and-push:
runs-on: runners-cartsnitch
needs: [lint, test]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate CalVer tag
id: calver
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
DATE_TAG=$(date -u +%Y.%m.%d)
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
if [ -z "$EXISTING" ]; then
VERSION="$DATE_TAG"
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then
VERSION="${DATE_TAG}.2"
else
BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//")
VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "CalVer tag: $VERSION"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
target: prod
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create git tag
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
git tag "v${{ steps.calver.outputs.version }}"
git push origin "v${{ steps.calver.outputs.version }}"
+10 -13
View File
@@ -3,24 +3,21 @@ FROM python:3.12-slim AS build
WORKDIR /app
# git is required to install cartsnitch-common from GitHub; build-essential and
# libpq-dev are needed to compile any C-extension wheels (e.g. psycopg2 fallback)
# build-essential and libpq-dev are needed to compile any C-extension wheels
# (e.g. psycopg2 fallback). No git needed — common/ is copied from the repo root.
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
libpq-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./
COPY src/ ./src/
# Build context is the repo root. These paths are relative to the root.
COPY receiptwitness/pyproject.toml ./
COPY receiptwitness/src/ ./src/
COPY common/ ./common/
# cartsnitch-common is not on PyPI — install it directly from GitHub, then
# install the rest of the package dependencies in a single resolver pass so
# pip can satisfy the cartsnitch-common>=0.1.0 constraint declared in
# pyproject.toml without hitting PyPI for it.
RUN pip install --no-cache-dir --prefix=/install \
"cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" \
.
# Install from the local common/ (cartsnitch-common>=0.1.0 in pyproject.toml
# will be satisfied by the local package) then install receiptwitness itself.
RUN pip install --no-cache-dir --prefix=/install ./common/ .
# Stage 2: Production image with Playwright + Chromium
FROM python:3.12-slim AS prod
@@ -51,7 +48,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN adduser --system --group --uid 1000 app
COPY --from=build /install /usr/local
COPY src/ ./src/
COPY receiptwitness/src/ ./src/
# Install Playwright Chromium browser (runs as root; /opt/playwright is world-readable)
RUN PLAYWRIGHT_BROWSERS_PATH=/opt/playwright playwright install chromium
+7 -1
View File
@@ -1,7 +1,13 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import App from './App.tsx'
vi.mock('./lib/auth-client.ts', () => ({
authClient: {
useSession: () => ({ data: null, isPending: false }),
},
}))
describe('App', () => {
it('renders the dashboard on the root route', () => {
render(<App />)
+45
View File
@@ -0,0 +1,45 @@
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { usePurchases } from '../useApi'
import { http, HttpResponse } from 'msw'
import { server } from '../../test/mocks/server'
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
}
describe('useApi hooks', () => {
describe('usePurchases', () => {
it('fetches and returns purchases', async () => {
const { result } = renderHook(() => usePurchases(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toHaveLength(1)
expect(result.current.data![0]).toMatchObject({
id: 'pur_1',
storeName: 'Kroger',
total: 42.5,
})
})
it('returns an error when the endpoint fails', async () => {
server.use(
http.get('/api/v1/purchases', () => HttpResponse.error()),
)
const { result } = renderHook(() => usePurchases(), { wrapper: createWrapper() })
await waitFor(() => expect(result.current.isError).toBe(true))
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001",
baseURL: import.meta.env.VITE_AUTH_URL || "",
basePath: "/auth",
})
+1 -1
View File
@@ -22,7 +22,7 @@ export function Login() {
setLoading(true)
try {
const { data, error: authError } = await authClient.signIn.email({
const { error: authError } = await authClient.signIn.email({
email,
password,
})
+1 -1
View File
@@ -28,7 +28,7 @@ export function Register() {
setLoading(true)
try {
const { data, error: authError } = await authClient.signUp.email({
const { error: authError } = await authClient.signUp.email({
name,
email,
password,
+65
View File
@@ -0,0 +1,65 @@
import { http, HttpResponse } from 'msw'
import type { Purchase, Product, Coupon, PriceAlert } from '../../types/api.ts'
const mockPurchases: Purchase[] = [
{
id: 'pur_1',
storeId: 'store_1',
storeName: 'Kroger',
date: '2024-01-15',
total: 42.5,
items: [
{ id: 'item_1', productId: 'prod_1', name: 'Milk', quantity: 1, price: 3.99, unitPrice: 3.99 },
{ id: 'item_2', productId: 'prod_2', name: 'Bread', quantity: 2, price: 5.98, unitPrice: 2.99 },
],
},
]
const mockProducts: Product[] = [
{
id: 'prod_1',
name: 'Whole Milk',
brand: 'Kroger',
category: 'Dairy',
prices: [{ storeId: 'store_1', storeName: 'Kroger', price: 3.99, lastUpdated: '2024-01-15' }],
},
{
id: 'prod_2',
name: 'Whole Wheat Bread',
brand: 'Nature\'s Own',
category: 'Bakery',
prices: [{ storeId: 'store_1', storeName: 'Kroger', price: 2.99, lastUpdated: '2024-01-15' }],
},
]
const mockCoupons: Coupon[] = [
{
id: 'coupon_1',
productId: 'prod_1',
storeName: 'Kroger',
description: '$1 off milk',
discount: '$1.00',
expiresAt: '2024-12-31',
code: 'MILK1',
},
]
const mockAlerts: PriceAlert[] = [
{
id: 'alert_1',
productId: 'prod_1',
productName: 'Whole Milk',
targetPrice: 2.99,
currentPrice: 3.99,
triggered: false,
},
]
export const handlers = [
http.get('/api/v1/health', () => HttpResponse.json({ status: 'ok' })),
http.get('/api/v1/purchases', () => HttpResponse.json(mockPurchases)),
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
http.get('/api/v1/price-alerts', () => HttpResponse.json(mockAlerts)),
]
+4
View File
@@ -0,0 +1,4 @@
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
+5
View File
@@ -1 +1,6 @@
import '@testing-library/jest-dom/vitest'
import { server } from './mocks/server'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
@@ -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');
});
});
+62
View File
@@ -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');
});
});
});
+46
View File
@@ -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');
});
});
});
+10
View File
@@ -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);
}
+34
View File
@@ -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`;
}
+13
View File
@@ -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;
}