diff --git a/.gitignore b/.gitignore index 4f256be..be87399 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,13 @@ /node_modules /.pnp .pnp.js +package-lock.json # testing /coverage +/test-results/ +/playwright-report/ +/playwright/.cache/ # database /prisma/db.sqlite diff --git a/app/components/AddSubscriptionPopover.tsx b/app/components/AddSubscriptionPopover.tsx index 204eb9f..a093a29 100644 --- a/app/components/AddSubscriptionPopover.tsx +++ b/app/components/AddSubscriptionPopover.tsx @@ -1,17 +1,21 @@ import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' import { zodResolver } from '@hookform/resolvers/zod' import { useLoaderData } from '@remix-run/react' -import { PlusCircle } from 'lucide-react' +import { CalendarIcon, PlusCircle } from 'lucide-react' import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' +import { Controller, useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' +import { cn } from '~/lib/utils' import type { loader } from '~/routes/_index' -import type { Subscription } from '~/store/subscriptionStore' +import type { BillingCycle, Subscription } from '~/store/subscriptionStore' +import { initializeNextPaymentDate } from '~/utils/nextPaymentDate' import { IconUrlInput } from './IconFinder' interface AddSubscriptionPopoverProps { @@ -24,6 +28,9 @@ const subscriptionSchema = z.object({ currency: z.string().min(1, 'Currency is required'), icon: z.string().optional(), domain: z.string().url('Invalid URL'), + billingCycle: z.enum(['monthly', 'yearly', 'weekly', 'daily']).optional(), + nextPaymentDate: z.string().optional(), + showNextPayment: z.boolean().optional(), }) type SubscriptionFormValues = z.infer @@ -32,6 +39,7 @@ export const AddSubscriptionPopover: React.FC = ({ const { rates } = useLoaderData() const [open, setOpen] = useState(false) const [shouldFocus, setShouldFocus] = useState(false) + const [calendarOpen, setCalendarOpen] = useState(false) const { register, @@ -41,6 +49,7 @@ export const AddSubscriptionPopover: React.FC = ({ setFocus, setValue, watch, + control, } = useForm({ resolver: zodResolver(subscriptionSchema), defaultValues: { @@ -49,10 +58,16 @@ export const AddSubscriptionPopover: React.FC = ({ price: 0, currency: 'USD', domain: '', + billingCycle: undefined, + nextPaymentDate: undefined, + showNextPayment: false, }, }) const iconValue = watch('icon') + const billingCycleValue = watch('billingCycle') + const showNextPaymentValue = watch('showNextPayment') + const nextPaymentDateValue = watch('nextPaymentDate') useEffect(() => { if (shouldFocus) { @@ -61,6 +76,17 @@ export const AddSubscriptionPopover: React.FC = ({ } }, [shouldFocus, setFocus]) + // Auto-calculate next payment date when billing cycle changes + useEffect(() => { + if (billingCycleValue && showNextPaymentValue) { + const currentDate = nextPaymentDateValue + if (!currentDate) { + const newDate = initializeNextPaymentDate(billingCycleValue) + setValue('nextPaymentDate', newDate) + } + } + }, [billingCycleValue, showNextPaymentValue, nextPaymentDateValue, setValue]) + const onSubmit = (data: SubscriptionFormValues) => { addSubscription(data) toast.success(`${data.name} added successfully.`) @@ -74,6 +100,16 @@ export const AddSubscriptionPopover: React.FC = ({ } }, [open, setFocus]) + const formatDateForDisplay = (dateString: string | undefined) => { + if (!dateString) return 'Pick a date' + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + } + return ( @@ -82,7 +118,7 @@ export const AddSubscriptionPopover: React.FC = ({ Add Subscription - +

Add Subscription

@@ -134,6 +170,74 @@ export const AddSubscriptionPopover: React.FC = ({

{errors.domain?.message}

+
+ + +
+ {billingCycleValue && ( + <> +
+ ( + + )} + /> + +
+ {showNextPaymentValue && ( +
+ + ( + + + + + + { + field.onChange(date?.toISOString().split('T')[0]) + setCalendarOpen(false) + }} + disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))} + initialFocus + /> + + + )} + /> +
+ )} + + )}
+
+ + ( + + )} + /> +

{'\u00A0'}

+
+ {watchedFields.billingCycle && ( + <> +
+ ( + + )} + /> + +
+ {watchedFields.showNextPayment && ( +
+ + ( + + + + + + { + field.onChange(date?.toISOString().split('T')[0]) + setCalendarOpen(false) + }} + disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))} + initialFocus + /> + + + )} + /> +

{'\u00A0'}

+
+ )} + + )}
{}} onDelete={() => {}} /> diff --git a/app/components/SubscriptionCard.tsx b/app/components/SubscriptionCard.tsx index af666c2..42c8db9 100644 --- a/app/components/SubscriptionCard.tsx +++ b/app/components/SubscriptionCard.tsx @@ -1,10 +1,12 @@ import { motion } from 'framer-motion' -import { Edit, Trash2 } from 'lucide-react' +import { Calendar, Edit, Trash2 } from 'lucide-react' import type React from 'react' +import { Badge } from '~/components/ui/badge' import { Button } from '~/components/ui/button' import { Card, CardContent } from '~/components/ui/card' import { LinkPreview } from '~/components/ui/link-preview' import type { Subscription } from '~/store/subscriptionStore' +import { calculateNextPaymentDate } from '~/utils/nextPaymentDate' interface SubscriptionCardProps { subscription: Subscription @@ -14,7 +16,7 @@ interface SubscriptionCardProps { } const SubscriptionCard: React.FC = ({ subscription, onEdit, onDelete, className }) => { - const { id, name, price, currency, domain, icon } = subscription + const { id, name, price, currency, domain, icon, billingCycle, nextPaymentDate, showNextPayment } = subscription // Sanitize the domain URL const sanitizeDomain = (domain: string) => { @@ -31,6 +33,29 @@ const SubscriptionCard: React.FC = ({ subscription, onEdi // Use custom icon if available, otherwise fall back to domain favicon const logoUrl = icon || defaultLogoUrl + // Calculate and format next payment date + const getNextPaymentDisplay = () => { + if (!showNextPayment || !billingCycle) { + return null + } + + const calculatedDate = calculateNextPaymentDate(billingCycle, nextPaymentDate) + if (!calculatedDate) { + return null + } + + const date = new Date(calculatedDate) + const formattedDate = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + + return formattedDate + } + + const nextPaymentDisplay = getNextPaymentDisplay() + return ( = ({ subscription, onEdi transition={{ type: 'spring', stiffness: 300, damping: 20 }} className={`group ${className}`} > - -
+ + {/* Billing Cycle Badge - Top Left */} + {billingCycle && ( + + per{' '} + {billingCycle === 'monthly' + ? 'Monthly' + : billingCycle === 'yearly' + ? 'Yearly' + : billingCycle === 'weekly' + ? 'Weekly' + : 'Daily'} + + )} + + {/* Next Payment Date - Below Billing Cycle */} + {nextPaymentDisplay && ( +
+ + {nextPaymentDisplay} +
+ )} + + {/* Edit/Delete Buttons - Top Right */} +
+ - {`${name} + {`${name}

{name}

diff --git a/app/store/subscriptionStore.ts b/app/store/subscriptionStore.ts index cc2d924..34a202b 100644 --- a/app/store/subscriptionStore.ts +++ b/app/store/subscriptionStore.ts @@ -1,6 +1,8 @@ import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' +export type BillingCycle = 'monthly' | 'yearly' | 'weekly' | 'daily' + export interface Subscription { id: string name: string @@ -8,6 +10,9 @@ export interface Subscription { currency: string domain: string icon?: string + billingCycle?: BillingCycle + nextPaymentDate?: string // ISO date string + showNextPayment?: boolean } interface SubscriptionStore { @@ -135,7 +140,10 @@ function isValidSubscription(sub: any): sub is Subscription { typeof sub.price === 'number' && typeof sub.currency === 'string' && typeof sub.domain === 'string' && - (sub.icon === undefined || typeof sub.icon === 'string') + (sub.icon === undefined || typeof sub.icon === 'string') && + (sub.billingCycle === undefined || ['monthly', 'yearly', 'weekly', 'daily'].includes(sub.billingCycle)) && + (sub.nextPaymentDate === undefined || typeof sub.nextPaymentDate === 'string') && + (sub.showNextPayment === undefined || typeof sub.showNextPayment === 'boolean') ) } diff --git a/app/utils/nextPaymentDate.ts b/app/utils/nextPaymentDate.ts new file mode 100644 index 0000000..9b3f1f8 --- /dev/null +++ b/app/utils/nextPaymentDate.ts @@ -0,0 +1,106 @@ +import type { BillingCycle } from '~/store/subscriptionStore' + +/** + * Calculate the next payment date based on billing cycle and current date + * Ensures the returned date is always in the future + */ +export function calculateNextPaymentDate( + billingCycle: BillingCycle | undefined, + currentNextPaymentDate?: string, +): string | undefined { + if (!billingCycle) { + return undefined + } + + const now = new Date() + let nextDate: Date + + // If we have a current next payment date, use it as the base + if (currentNextPaymentDate) { + nextDate = new Date(currentNextPaymentDate) + + // If the date is in the future, keep it + if (nextDate > now) { + return currentNextPaymentDate + } + + // Otherwise, calculate the next occurrence + nextDate = getNextOccurrence(nextDate, billingCycle, now) + } else { + // No existing date, calculate from now + nextDate = getNextOccurrence(now, billingCycle, now) + } + + return nextDate.toISOString().split('T')[0] // Return YYYY-MM-DD format +} + +/** + * Get the next occurrence of a payment date after the current date + */ +function getNextOccurrence(baseDate: Date, billingCycle: BillingCycle, now: Date): Date { + const result = new Date(baseDate) + + switch (billingCycle) { + case 'daily': + // Add days until we're in the future + while (result <= now) { + result.setDate(result.getDate() + 1) + } + break + + case 'weekly': + // Add weeks until we're in the future + while (result <= now) { + result.setDate(result.getDate() + 7) + } + break + + case 'monthly': + // Add months until we're in the future + while (result <= now) { + result.setMonth(result.getMonth() + 1) + } + break + + case 'yearly': + // Add years until we're in the future + while (result <= now) { + result.setFullYear(result.getFullYear() + 1) + } + break + } + + return result +} + +/** + * Initialize a next payment date from today based on billing cycle + */ +export function initializeNextPaymentDate(billingCycle: BillingCycle | undefined): string | undefined { + if (!billingCycle) { + return undefined + } + + const now = new Date() + const nextDate = new Date(now) + + switch (billingCycle) { + case 'daily': + nextDate.setDate(nextDate.getDate() + 1) + break + + case 'weekly': + nextDate.setDate(nextDate.getDate() + 7) + break + + case 'monthly': + nextDate.setMonth(nextDate.getMonth() + 1) + break + + case 'yearly': + nextDate.setFullYear(nextDate.getFullYear() + 1) + break + } + + return nextDate.toISOString().split('T')[0] +} diff --git a/package.json b/package.json index 4996118..b7192de 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "format": "biome format", "check": "biome check", "ci": "biome ci", + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", "docker:build": "docker build . -t ajnart/subs", "docker:test": "docker run -p 3000:3000 --rm --name subs ajnart/subs" }, @@ -92,6 +95,7 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@playwright/test": "^1.56.1", "@remix-run/dev": "^2.13.1", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d68c189 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..440b990 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,59 @@ +# Subscription Card Height Tests + +## Overview +This test suite verifies that subscription cards maintain consistent height regardless of their content (with or without billing cycle information). + +## Prerequisites +1. Install dependencies: `npm install` +2. Install Playwright browsers: `npx playwright install chromium` + +## Running the Tests + +### Run all tests +```bash +npm test +``` + +### Run tests with UI +```bash +npm run test:ui +``` + +### Run tests in headed mode (see browser) +```bash +npm run test:headed +``` + +### Run specific test file +```bash +npx playwright test tests/subscription-card-height.spec.ts +``` + +## Test Coverage + +### 1. All cards have the same height +Verifies that all subscription cards on the page have identical heights. + +### 2. Height remains consistent after adding monthly billing +Tests that adding a monthly billing cycle and next payment date to a subscription doesn't increase the card height. + +### 3. Cards have a square aspect ratio (1:1) +Verifies that cards are approximately square with a 1:1 width-to-height ratio (with 10% tolerance). + +### 4. Cards with billing cycle badge are not taller +Tests that newly created subscriptions with billing cycle information have the same height as existing cards. + +## Implementation Details + +The card height consistency is achieved through: +- Using `aspect-square` Tailwind class to enforce 1:1 aspect ratio +- The CSS Grid layout ensures all cards in a row have the same dimensions +- All cards use `data-testid="subscription-card"` for easy testing + +## Troubleshooting + +If tests fail: +1. Ensure the dev server is running properly +2. Check that the database has at least 2 subscriptions for comparison +3. Verify that the browser window size is consistent +4. Look at test screenshots in `test-results/` directory for visual debugging diff --git a/tests/subscription-card-height.spec.ts b/tests/subscription-card-height.spec.ts new file mode 100644 index 0000000..f4afb1f --- /dev/null +++ b/tests/subscription-card-height.spec.ts @@ -0,0 +1,134 @@ +import { test, expect } from '@playwright/test' + +test.describe('Subscription Card Height Consistency', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + // Wait for the page to load + await page.waitForSelector('[data-testid="subscription-card"]', { timeout: 10000 }) + }) + + test('all subscription cards should have the same height', async ({ page }) => { + // Get all subscription cards + const cards = await page.locator('[data-testid="subscription-card"]').all() + + // Ensure we have at least 2 cards to compare + expect(cards.length).toBeGreaterThanOrEqual(2) + + // Get heights of all cards + const heights = await Promise.all( + cards.map(async (card) => { + const box = await card.boundingBox() + return box?.height || 0 + }) + ) + + // All heights should be the same (within 1px tolerance for rounding) + const firstHeight = heights[0] + for (let i = 1; i < heights.length; i++) { + expect(Math.abs(heights[i] - firstHeight)).toBeLessThanOrEqual(1) + } + }) + + test('card height should remain consistent after adding monthly billing', async ({ page }) => { + // Get initial card heights + const cardsBefore = await page.locator('[data-testid="subscription-card"]').all() + const heightsBefore = await Promise.all( + cardsBefore.map(async (card) => { + const box = await card.boundingBox() + return box?.height || 0 + }) + ) + + // Click edit on the first subscription + await page.locator('button:has-text("Edit")').first().click() + + // Wait for modal to open + await page.waitForSelector('text=Edit Subscription', { timeout: 5000 }) + + // Select monthly billing cycle + await page.locator('[id="billingCycle"]').click() + await page.locator('text=Monthly').click() + + // Enable next payment date + await page.locator('switch:has-text("Show next payment date")').click() + + // Save the subscription + await page.locator('button:has-text("Save")').click() + + // Wait for modal to close and page to update + await page.waitForTimeout(1000) + + // Get card heights after adding billing info + const cardsAfter = await page.locator('[data-testid="subscription-card"]').all() + const heightsAfter = await Promise.all( + cardsAfter.map(async (card) => { + const box = await card.boundingBox() + return box?.height || 0 + }) + ) + + // All cards should still have the same height + const firstHeightAfter = heightsAfter[0] + for (let i = 1; i < heightsAfter.length; i++) { + expect(Math.abs(heightsAfter[i] - firstHeightAfter)).toBeLessThanOrEqual(1) + } + + // Heights should be consistent before and after + expect(Math.abs(heightsAfter[0] - heightsBefore[0])).toBeLessThanOrEqual(1) + }) + + test('cards should have a square aspect ratio (approximately 1:1)', async ({ page }) => { + // Get all subscription cards + const cards = await page.locator('[data-testid="subscription-card"]').all() + + // Check aspect ratio for each card + for (const card of cards) { + const box = await card.boundingBox() + if (box) { + const aspectRatio = box.width / box.height + // Aspect ratio should be close to 1 (square), allowing 10% tolerance + expect(aspectRatio).toBeGreaterThan(0.9) + expect(aspectRatio).toBeLessThan(1.1) + } + } + }) + + test('card with billing cycle badge should not be taller than others', async ({ page }) => { + // Add a new subscription with monthly billing + await page.locator('button:has-text("Add Subscription")').click() + await page.waitForSelector('text=Add Subscription', { timeout: 5000 }) + + // Fill in subscription details + await page.locator('[id="name"]').fill('Test Subscription') + await page.locator('[id="price"]').fill('9.99') + await page.locator('[id="domain"]').fill('https://test.com') + + // Select monthly billing + await page.locator('[id="billingCycle"]').click() + await page.locator('text=Monthly').last().click() + + // Enable next payment + await page.locator('switch:has-text("Show next payment date")').click() + + // Save + await page.locator('button:has-text("Save")').click() + + // Wait for page to update + await page.waitForTimeout(1000) + + // Get all card heights + const cards = await page.locator('[data-testid="subscription-card"]').all() + const heights = await Promise.all( + cards.map(async (card) => { + const box = await card.boundingBox() + return box?.height || 0 + }) + ) + + // All heights should be the same + const firstHeight = heights[0] + for (let i = 1; i < heights.length; i++) { + expect(Math.abs(heights[i] - firstHeight)).toBeLessThanOrEqual(1) + } + }) +})