Skip to content

Developer Guide - Deckly

Welcome to the Deckly codebase. This document is the source of truth for the technical architecture, core service layers, and development workflows.


Tech Stack

  • Frontend: React 19 + Vite + TypeScript (SPA)
  • Styling: Tailwind CSS (utility classes) + shadcn/ui (component primitives: Tabs, Button, Badge, Table, etc.) + Custom CSS tokens in App.css / index.css
  • Backend-as-a-Service: Supabase (PostgreSQL, Storage, Auth)
  • PDF Engine: pdfjs-dist (Browser-side Canvas/WebP rendering)
  • Data Fetching: TanStack Query (v5) - Core async state & caching
  • Edge Functions: Deno + Supabase Functions (sign-deck-url, document-processor, delete-account) authenticated with PROJECT_SECRET_KEY for owner-mode operations (e.g. signing private assets).
  • Analytics: PostHog (Event Analysis) + Supabase RPCs (record_deck_visit, get_deck_locations, count_unique_visitors)
  • Geo-Location: Vercel Edge Functions (/api/geo) capturing header-based ingress metadata
  • Security: Hardened via VITE_SUPABASE_PUBLISHABLE_KEY, Asymmetric JWT Signing, and Transactional Advisory Locks for race-condition prevention.
  • Icons: Lucide React

Project Structure

text
src/
├── components/     # UI Components (Modals, Panels, Gates)
│   ├── common/     # Atomic UI atoms (Buttons, Toggles, Inputs, ConfirmModal)
│   │   ├── ConfirmModal.tsx        # High-end, animated confirmation dialog
│   ├── dashboard/  # Dashboard-specific cards and charts
│   │   ├── AnalyticsChart.tsx      # Responsive bar chart (flex-1 max-w-12 bars)
│   │   ├── AnalyticsDashboard.tsx  # Tabbed analytics overview card
│   │   ├── AnalyticsStatsSection.tsx
│   │   ├── ContentStatsCard.tsx    # Inline stat row for Content page
│   │   └── DecksTable.tsx          # Desktop table / Mobile card-list
│   ├── layout/     # Global layout shell
│   │   ├── DashboardLayout.tsx     # Sidebar + Header + FAB + BottomNav
│   │   ├── Sidebar.tsx             # Desktop-only navigation (md+)
│   │   └── BottomNav.tsx           # Mobile-only bottom nav (< md)
│   └── dashboard/
│       └── MascotSettingsModal.tsx # Branding customization UI
├── constants/      # Tier definitions and theme tokens
│   └── tiers.ts               # Tier type, TierConfig (days, maxDataRooms)
├── contexts/       # Global State (Auth, Feature Gating, Branding)
├── hooks/          # Domain-specific logic (useProfile, useDecks)
├── pages/
│   ├── DataRoomsPage.tsx      # Room listing with tier enforcement
│   ├── DataRoomDetail.tsx     # Per-room detail view
│   ├── ManageDataRoom.tsx     # Create / Edit room form
│   ├── DataRoomViewer.tsx     # Public room viewer
│   ├── DeckAnalytics.tsx      # Per-deck detailed engagement page
│   ├── Viewer.tsx             # Deck viewer with access protection
│   ├── LegacyRedirect.tsx     # Gracefully handles legacy URL structures
│   └── Home.tsx               # Root redirect

├── services/       # Core Business Logic (The "Brain")
│   ├── deckService.ts              # Composed facade over deck-focused modules
│   ├── deckStorageService.ts
│   ├── deckLibraryService.ts
│   ├── deckBrandingService.ts
│   ├── authSession.ts
│   ├── analyticsService.ts         # Heartbeat tracking & Aggregation (RPC Optimized)
│   ├── dataRoomService.ts          # Room CRUD, documents, analytics
│   ├── interestSignalService.ts    # Visitor engagement signal computation
│   └── userService.ts              # Profile & Tier management
├── utils/          # Shared Utilities
│   └── url.ts                      # Centralized share-link & routing logic
└── types/          # Global TypeScript interfaces

Core Workflows

1. The PDF processing Pipeline

To keep costs low and privacy high, we avoid server-side PDF processing.

  • Location: ManageDeck.tsx + useManageDeckWorkflow.ts + workflows/deckProcessing.ts.
  • Logic: User selects PDF → shared workflow renders pages and extracts links → blobs are uploaded through deckStorageService / deckService.uploadSlideImages().

2. Real-time Analytics Heartbeat

Tracking exact slide-level dwell time without losing data on tab close.

  • Location: Viewer.tsx + analyticsService.syncSlideStats().
  • Logic: A timer tracks active viewing. Every 5s (or on slide change), we sync the time to deck_page_views (per-slide granularity) and deck_stats (aggregate) in Supabase.
  • Deduplication: We use a persistent visitorId in localStorage to ensure unique view counts over a 24-hour sliding window.
  • Email linking: When AccessGate captures an email, it is stored in viewer_email on deck_page_views and displayed in the Visitor Engagement Signals section.

3. Layered Performance Optimization

We maintain a "one step ahead" user experience by preparing the environment before actions are taken.

  • Location: Home.tsx, Sidebar.tsx, individual Repository components.
  • Logic:
    • Global: Dashboard initializes and pre-loads heavy component chunks while prefetching core index metadata in the background.
    • Intent: Navigation items (Sidebar/Cards) use onMouseEnter to pre-load specific JS bundles and trigger target data queries before the click occurs.
    • Balanced: Heavy analytics and binary assets are excluded from pre-loading to maintain minimal database and bandwidth overhead.

3. Protection Interceptors & Asset Signing

Intercepting the viewer for password/email gates.

  • Location: AccessGate.tsx.
  • Logic:
    1. The Viewer component renders the AccessGate based on deck metadata.
    2. Access is verified via get_deck_payload and check_deck_password RPCs.
    3. Asset Signing: Once authenticated, the client calls the sign-deck-url Edge Function with the storage_path.
    4. IDOR Protection: The Edge Function re-fetches the canonical path from the database to ensure the user is not attempting to sign an unauthorized file path.
    5. Refresh Logic: Viewer.tsx implements a signedUrlMeta refresh loop that re-signs the URL 60s before its 1-hour expiry, ensuring session longevity.
    6. Log Redaction: All Edge Functions implement automatic PII redaction (UUIDs, internal paths) to ensure security compliance in observability logs.

To prevent broken links during routing schema updates, we centralize all public and internal URL generation.

  • Location: src/utils/url.ts.
  • Logic: All share-links (copy-to-clipboard) and internal navigation items (React Router Link components) must use the helpers in url.ts. This ensures that if the routing pattern changes (e.g., from /:handle/:slug to /v/:handle/:slug), we only need to update the logic in one file to maintain consistency across seven different components.

Key Services

deckService.ts

  • Public facade used by the app
  • Internally composed over narrower modules for CRUD, storage, branding, and library behavior
  • Kept as a stable API while reducing service sprawl

authSession.ts

  • Shared getSessionUserId() and getRequiredSessionUserId() helpers
  • Used to reduce repeated ad-hoc supabase.auth.getSession() calls across services

analyticsService.ts

  • Retention: Automatically filters stats based on the user's tier (7 days for Free, 90 days for Pro).
  • Server-Side Aggregation: Optimized via count_unique_visitors and get_deck_locations RPCs to avoid client-side memory bloat.
  • Unique Visitors: Uses p_deck_id UUID params to count distinct visitor_ids directly in PostgreSQL.

interestSignalService.ts

  • Signal Computation: Queries deck_page_views grouped by visitor_id, computes 5 engagement signals (Revisited, Viewed multiple times, Deep read, Quick return, Extended viewing).
  • Slide Breakdown: Returns per-slide time data (slideBreakdown) for each visitor, rendered as a vertical bar chart.
  • Email Resolution: Returns viewer_email when available, enabling the UI to show actual emails instead of anonymous IDs.

dataRoomService.ts

  • CRUD: Full create/read/update/delete for data rooms with Supabase.
  • Document Management: Junction table (data_room_documents) links decks to rooms. Supports add, remove, reorder.
  • Analytics Aggregation: getDataRoomAnalytics() aggregates visitor counts across all documents in a room.
  • Icon Upload: uploadRoomIcon() handles custom room icon uploads to Supabase Storage.

Mobile Layout System

The dashboard is fully responsive using a Tailwind md breakpoint (768px) as the single split point between mobile and desktop experiences.

ScreenNavigation ComponentLocation
Mobile (< md)BottomNav.tsxFixed bottom, z-50
Desktop (≥ md)Sidebar.tsxFixed left column

Both components read from the same navItems array but render differently. BottomNav is icon-only with an active dot; Sidebar shows labels and a user profile block.

Layout Rules for Screen Fit

  • Content padding: Always use p-4 md:p-8 pattern. Never hard-code large padding without a mobile fallback.
  • Bottom clearance: Pages inside DashboardLayout get pb-20 md:pb-0 on the main scroll area so content is never hidden behind the bottom nav bar.
  • FAB position: The Floating Action Button sits at bottom-24 on mobile (above the nav bar) and bottom-10 on desktop.
  • Tables on mobile: Use the dual-render pattern — wrap the <Table> in hidden md:block and add a md:hidden card list above it. This avoids horizontal scroll and gives a native app feel.
  • Custom Scrollbars: Native browser scrollbars on interactive elements like data tables have been replaced with a slim, branded custom-scrollbar utility from index.css.
  • Font scaling: Stat values use text-2xl md:text-5xl — never go full-size on mobile; always include a smaller mobile variant.
  • Grids: Summary card grids use grid-cols-2 md:grid-cols-4 (2×2 on mobile, 1×4 on desktop).

The AnalyticsChart Responsive Pattern

The custom bar chart avoids a fixed-width layout by using:

tsx
// Each bar column
<div className="flex-1 max-w-12 h-full ...">
  {/* Bar fills 3/4 of column width */}
  <div className="w-3/4 mx-auto bg-slate-900 rounded-full ..." />
</div>
  • flex-1 — fills available space naturally (no overflow on mobile).
  • max-w-12 — caps bar width at 48px on wide desktop screens.
  • X-axis labels mirror the same flex-1 max-w-12 truncate pattern.

User Management & Sessions

We use Supabase Auth for identity management. The primary lifecycle is managed in src/contexts/AuthContext.tsx.

The Auth Lifecycle & Watchdog

  1. The Race: On app initialization, we race the supabase.auth.getSession() call against a 10s timeout. This prevents the app from hanging forever if the database is "cold" or the network is slow.
  2. Session Persistence: Once a session is retrieved, it's stored in React state.
  3. Profile & Branding Sync: The context immediately triggers a call to userService.getProfile(userId) and deckService.getBrandingSettings(userId) to fetch the user's tier and custom branding (logo/name).
  4. Branding Persistence: Branding state is cached in localStorage inside AuthContext to ensure custom logos are available instantly on hard refreshes, preventing the default "penguin" flicker.
  5. Subscription: We listen to onAuthStateChange to reactively update the UI when users sign in, sign out, or their profile changes.

Pro Tier Gating

The isPro variable is exposed globally via useAuth(). It evaluates to true if the user_tier is PRO or PRO_PLUS. This gates:

  • Password protection settings in ManageDeck.
  • Extended analytics retention visibility.
  • Data room creation limits (Free: 1, Pro: 5, Pro+: unlimited).

Google/GitHub OAuth Auto-Profile

When a user signs in via OAuth (Google, GitHub) for the first time, AuthContext.fetchProfile() detects that no profiles row exists and auto-creates one:

  1. Calls supabase.auth.getUser() to read auth metadata
  2. Creates a profile row with full_name, avatar_url from metadata, and tier: "FREE"
  3. Re-fetches the profile so the UI immediately reflects the user's name and tier badge

This ensures OAuth users get the same experience as email sign-up users without requiring a database trigger.


The PDF Data Pipeline

Each document is processed entirely in the browser to ensure privacy and eliminate server processing costs.

  1. Upload: The original source PDF is pushed to decks storage.
  2. Rendering: pdfjs-dist is used to load the PDF. It loops through each page and renders it to a Hidden Canvas at 1.5x scale (for High DPI clarity).
  3. Image Conversion: The canvas is converted to a WebP Blob (80% quality).
  4. Concurrent Push: deckService.uploadSlideImages() delegates to the storage layer and uses a Concurrency Limit of 3 with retry/backoff logic.

Database Architecture

The schema is optimized for high-speed retrieval of analytics. See supabase/schema.sql for the full blueprint.

Table Schema

  • decks: The core document record. Includes the pages column (an array of strings) which stores the direct URLs to the processed WebP images.
  • profiles: Extended user data managed by userService. Includes tier (FREE, PRO, PRO_PLUS) for feature gating.
  • deck_stats: An aggregate table used for the dashboard (per-slide totals).
  • deck_page_views: Granular per-visitor per-slide view records. Stores visitor_id, viewer_email, time_spent, and viewed_at. Used for unique visitor counting and interest signal computation.
  • data_rooms: Room metadata — name, slug, description, icon, access controls.
  • data_room_documents: Junction table for N-M relationship between rooms and decks.

Pro-Tip: Storage RLS & Metadata

When writing INSERT or UPDATE policies for Supabase Storage, always use COALESCE for metadata-based checks.

sql
-- Pattern for resilience against async metadata processing
(metadata->>'property')::type -- ❌ Risky (fails if property is null/missing)
COALESCE((metadata->>'property')::type, default) -- ✅ Safe

Supabase Storage often performs an initial database insert before metadata is fully extracted. Without the COALESCE guard, your RLS check will return NULL and fail the request.

Monitoring Edge Functions

The sign-deck-url function provides transparent debugging via structured logs. When an RPC failure occurs, the function captures and logs:

  • message: The human-readable error.
  • hint / details: Context provided by PostgreSQL (e.g., 'table not found' or 'type mismatch').
  • code: The Supabase/Postgrest error code (e.g., PGRST116).

Note on Privacy: All Edge Functions redact sensitive user IDs and internal storage paths from logs to prevent PII exposure.

Check the Functions > Logs tab in the Supabase Dashboard to diagnose permission issues or unauthorized access attempts.

  • data_room_documents: Junction table linking decks to rooms with display_order for custom ordering.

High-Performance Analytics Heartbeat

Tracking exact slide-level dwell time:

  1. When a user opens a deck in Viewer.tsx, a timer starts.
  2. analyticsService.syncSlideStats() is called whenever a user changes slides or closes the browser tab.
  3. The Sync: It uses the record_deck_visit RPC. This is a SECURITY DEFINER function that performs server-side validation of the payload, updates deck_stats (atomically), and inserts into deck_page_views. It correctly parses x-forwarded-for to identify unique client IPs for accurate rate limiting.
  4. Unique Visitors: "Total Visit" counts are derived from COUNT(DISTINCT visitor_id) on deck_page_views. Daily metrics in getDailyMetrics() use a composite unique key of (visitor_id, deck_id, date) to ensure the dashboard reflects "Daily Deck Views" rather than raw slide turns. Oversized visitor IDs are automatically truncated to prevent database corruption.
  5. Real Engagement Data: The getDailyMetrics() service aggregates actual time_spent from database records instead of using simulated placeholders.
  6. Owner Exclusion: All viewer components (ImageDeckViewer, DeckViewer) and the useDeckAnalytics hook accept an isOwner flag. If true, tracking calls to analyticsService are bypassed to maintain data integrity.
  7. The Index: We use a composite index idx_deck_stats_dashboard on (deck_id, user_id, updated_at) to ensure the analytics modal opens instantly even for decks with hundreds of views.

Development Workflows

1. Atomic UI Modification

When adding a new button or input, always add it to src/components/common/. Do NOT write ad-hoc CSS for individual buttons; use the design tokens in App.css or index.css.

2. Data Fetching & State

We use TanStack Query for all asynchronous data fetching. Services should remain as "pure" data-fetching wrappers around Supabase. All caching logic belongs in the Query Client. See TANSTACK_QUERY.md for detailed patterns.

3. Refactor conventions

  • Keep heavy orchestration out of pages when possible
  • Prefer workflow hooks for screen-specific orchestration
  • Prefer focused service modules behind a stable facade
  • Prefer shared auth/session helpers over repeated inline session resolution

4. Migrations

Deckly uses a strict CLI-First database strategy. All schema changes must be authored as migrations in supabase/migrations/ and applied via the CLI.

  • The Baseline: 00000000000000_initial_schema.sql is the consolidated source of truth for the entire platform.
  • Transactional Safety: High-concurrency flows (like signup throttling) utilize pg_advisory_xact_lock(hashtext(v_ip)) within security-definer functions to ensure atomicity.
  • Rule of Thumb: Never use the Supabase Web SQL Editor for production-bound features.

Local Development & Security

1. RLS Ownership Requirements

When testing Data Room document management locally, be aware of the hardened RLS policy on data_room_documents.

  • The Rule: You can only link a Deck to a Data Room if your auth.uid() owns both entities.
  • Testing tip: If you manually insert records into data_room_documents for testing, ensure the user_id on the referenced decks row matches the user_id on the data_rooms row.

2. High-Performance Batching

The dashboard uses the get_batch_data_room_analytics RPC to avoid N+1 query bottlenecks.

  • Implementation: See dataRoomService.getBatchDataRoomAnalytics().
  • Fallback: The service automatically falls back to client-side aggregation if the RPC is missing, ensuring the UI remains functional during partial database migrations.

3. Verification workflow

Before closing structural work, run:

bash
npm run type-check
npm run lint
npm test

The Vitest pipeline is now healthy and should be treated as a reliable local check.

Built with ❤️ for Founders