diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 8fd5b40..64deae4 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -5,7 +5,6 @@ on: branches: - main - dev - - "feature/**" pull_request: branches: - main @@ -14,8 +13,17 @@ on: permissions: contents: read +concurrency: + # PR runs share a group per PR so a superseded head is cancelled. Push runs get + # a unique group so a queued dev/main run is never replaced by a newer push; + # the promotion skip relies on every dev SHA keeping its own verify run. + group: website-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: verify: + # Only this repository's dev-to-main promotion has a matching dev push run. + if: github.event_name != 'pull_request' || github.head_ref != 'dev' || github.base_ref != 'main' || github.event.pull_request.head.repo.id != github.event.repository.id runs-on: ubuntu-latest timeout-minutes: 15 defaults: @@ -23,9 +31,11 @@ jobs: working-directory: frontend steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.x cache: npm @@ -36,10 +46,14 @@ jobs: run: npm run lint - name: Type-check run: npm run type-check + - name: Verify workflow event boundaries + run: node --test test/workflow.test.mjs - name: Run scene tests run: npm run test:scene - name: Run PostHog contracts run: npm run test:posthog + - name: Run support contracts + run: npm run test:support - name: Build production app run: npm run build - name: Install browser for production checks diff --git a/ROADMAP.md b/ROADMAP.md index 5f36304..e7f1162 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,35 +3,57 @@ Loose, personal roadmap for the CodesWhat homepage. Not a ticket tracker (that's work). Just the ideas worth not forgetting. -## Progressive-enhancement fallback for the 3D layer +## Where things stand (2026-10-25) + +The Catalogue redesign, the `/support` page and the analytics events shipped on +2026-10-22 (#69, #70, #71). Production is `main@68520ee`. Two things are in +flight: -The homepage renders its coin, mascot balloons, and the `@` marble live in WebGL -(three.js + react-three-fiber). That buys real interactivity: the balloons track -scroll and dodge the cursor, the marble orbits and flees. The cost isn't mainly -bandwidth (the three/fiber/drei bundle is ~300KB gzipped, loaded once and cached, -and the texture PNGs load either way). The cost is compute: WebGL burns CPU/GPU -and battery and adds parse + hydration time, which hurts on cheap phones. +- [ ] Dependency security patch (Next 16.4.3, dompurify, sharp): PR #72 is green + on CI and preview and waiting on a delivered review before it lands in `dev`, + then a promotion to `main`. +- [ ] CI hardening (support tests in `verify`, no duplicate promotion runs, + fork-safe skip, no persisted checkout credential, actions pinned to + commits): finished and tested locally, queued behind #72. +- [ ] Remove the stray `visual-schedule-manager-portfolio/` app and root + `start.sh`; nothing references either. +- [ ] Clean up `test/posthog.test.ts`, which imports a posthog-js internal path, + then bump `posthog-js` and add `test:posthog:browser` to CI. +- [ ] Close #58 (`.claude/` is already ignored on `main`). #57 is partly done; + the remaining items are tracked privately. + +## Progressive-enhancement fallback for the 3D layer -Baked assets (the WebP loops in `exports/`) are the opposite: nearly free to -render, but dumb. They can't track scroll or react to the pointer. +**Superseded 2026-10-22.** The Catalogue redesign replaced the WebGL homepage +with CSS and pixel-sprite specimen plates (`components/catalogue/sprites.ts`). +three.js, react-three-fiber, drei and rapier are now only imported under +`components/studio/`, which backs the dev-only `/studio/*` capture routes. There +is no 3D layer on the shipped homepage to gate or fall back from, so the original +checklist below is moot and kept for the reasoning only. -So the move for a lighter homepage isn't ripping WebGL out. It's a fallback: -serve flat assets by default and upgrade to WebGL when the device can handle it, -or when `prefers-reduced-motion` is off. That's more code, not less, but it gives -weak devices and reduced-motion users a cheap, static homepage while everyone -else still gets the interactive scene. +The original idea: the homepage rendered its coin, mascot balloons and the `@` +marble live in WebGL. That bought real interactivity (balloons tracking scroll and +dodging the cursor, the marble orbiting and fleeing) at the cost of compute, +battery and hydration time on cheap phones. Baked WebP loops were nearly free to +render but couldn't react to anything. The plan was to serve flat assets by +default and upgrade to WebGL behind `prefers-reduced-motion` and a capability +check, lazy-loading the 3D bundle only on the upgrade path, and to measure First +Load JS and TTI for both paths rather than guess. -- [ ] Static fallback layer (coin WebP + balloon stills positioned to match the scene) -- [ ] Gate WebGL behind `prefers-reduced-motion` + a capability/perf check -- [ ] Lazy-load the 3D bundle only when the upgrade path is taken -- [ ] Measure: real First Load JS + TTI for both paths (build, don't guess) +- [ ] Decide whether the three/fiber/drei/rapier dependencies stay for the + `/studio` capture rig or go. They're dev-only but still in `package.json` + and the lockfile, so they show up in audits (`fflate` moderate via drei). ## Coherent 3D brand-asset / icon set -`scripts/capture-assets.mjs` mints transparent assets straight from the live scene -(dev-only `/studio/*` routes → headless Chrome → ffmpeg/img2webp). It already does -the coin (spin) and any mascot balloon (square avatar still + spin). Make the whole -icon/image story coherent off the back of it: +**Parked 2026-10-25.** The Catalogue uses hand-drawn pixel sprites for all eight +projects plus the coin, matched to the real project logos, so the 3D-minted +icon set isn't on the critical path any more. `scripts/capture-assets.mjs` and +the `/studio/*` routes still work and still mint the coin spin and any mascot +balloon (square avatar still + spin). What exists today: `exports/coin/` +(light and dark spin WebP) and `exports/portkey/` (avatar PNG + spin WebP), plus +static balloon PNGs for drydock, portkey, portwing, sockguard and scott in +`frontend/public/logos/`. - [ ] Avatar + spin for every mascot (Sockguard, Drydock, Portwing, Portkey), not just Portkey - [ ] Decide where committed assets live and how they're named (currently `exports//`, WebP + still PNG tracked, GIF/APNG gitignored) @@ -40,26 +62,25 @@ icon/image story coherent off the back of it: ## Web-analytics table coverage (2026-08-28, ops X37) -Production events carry no `$pathname` and no `$pageleave`, so PostHog's Page / -Entry / Exit tables render empty and every session counts as a zero-duration -bounce. The wiring for both landed on dev as PR #60 (sanitizer allowlists -`$pageleave` and stamps `$pathname` from the already-sanitized `path`) and -ships with the next promotion — the items below are what's left after that. +Production events carried no `$pathname` and no `$pageleave`, so PostHog's Page / +Entry / Exit tables rendered empty and every session counted as a zero-duration +bounce. The wiring for both landed as PR #60 and has been in production since the +September promotions. -- [ ] Verify post-deploy that `$pathname` shows on fresh pageviews and the - Page/Entry/Exit tables populate (board: CodesWhat Sites Health, - project 558033, dashboard 2044260) -- [ ] `$pageleave` volume note (resolved 2026-08-28, standard `19a0af1`): a +- [x] Verified 2026-10-25 against PostHog project 558033 (host `codeswhat.com`, + last 30 days): 30 `$pageview`, all 30 carrying `$pathname`; 9 `$pageleave`; + 22 `$web_vitals`. The Page/Entry/Exit tables have data to render. +- [x] `$pageleave` volume note (resolved 2026-08-28, standard `19a0af1`): a pageleave-to-pageview ratio well under 100% is structural, not a bug — `$pageview` fires per client-side route change, `$pageleave` once per document lifetime. drydock measured the envelope: the sanitizer guard eats nothing. Don't "fix" the ratio here. -- [ ] Post-deploy canary caveat: posthog-js bot detection (`isLikelyBot` - checks `navigator.webdriver` and `userAgentData.brands`) may silently - drop Playwright-driven visits before `before_send` runs; contested - (portwing's landed), so a Playwright canary's silence proves nothing - either way. Verify with a real browser or by reading the deployed chunk - for the property names. +- [x] Post-deploy canary caveat: moot now that real traffic confirms the + properties. For the record, posthog-js bot detection (`isLikelyBot` on + `navigator.webdriver` and `userAgentData.brands`) may silently drop + Playwright-driven visits before `before_send` runs, so a Playwright canary's + silence proves nothing either way. Verify with a real browser or by reading + the deployed chunk. - [x] Acquisition data: decided org-wide in `CodesWhat/ops` `standards/analytics.md` ("Acquisition data and consent", as of `c161ebc`) — that file is the authority. Landed: `save_campaign_params: @@ -74,10 +95,13 @@ ships with the next promotion — the items below are what's left after that. (cookieless strips the IP upstream, PostHog #48660). No banner needed. - [x] `$web_vitals` carries `$pathname` from the sanitized path - [x] `cta activated` for the project cards: `project_` ids, placement - `projects`. `cta activated` had zero rows in 120 days on codeswhat.com; - reproduced in a browser and the capture, sanitizer and send path all work, - so it was low traffic (76 pageviews) rather than a bug. Check it fires after - deploy. + `projects`. Shipped 2026-10-22 (#68/#70). Capture, sanitizer and send path + were reproduced in a browser before shipping. +- [ ] Production fire check for `cta activated`, `newsletter submitted` and + `client error`: none of the three has a row for host `codeswhat.com` in the + 30 days to 2026-10-25. With 30 pageviews in that window that's low traffic, + not evidence of a bug, so leave this open until one lands or a real-browser + click on production is checked in PostHog. - [x] `newsletter submitted` (`result`, plus `error_class` from invalid_email / rate_limited / unavailable / network on error) - [x] `client error` (`error_kind`, allowlisted `error_name`, sanitized path; diff --git a/frontend/test/workflow.test.mjs b/frontend/test/workflow.test.mjs new file mode 100644 index 0000000..6ce9b13 --- /dev/null +++ b/frontend/test/workflow.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { runInNewContext } from "node:vm"; + +const workflow = readFileSync( + new URL("../../.github/workflows/website.yml", import.meta.url), + "utf8", +); +const condition = workflow.match(/^ {4}if: (.+)$/m)?.[1]; +assert.ok(condition, "verify must have an explicit duplicate-run condition"); + +// This condition uses only boolean operators and same-type comparisons, shared +// by JavaScript and Actions for these lowercase string/numeric fixtures. +function runsVerify(github) { + return runInNewContext(condition, { github }, { timeout: 100 }); +} + +function pullRequest(head, base, headRepository = 1) { + return { + event_name: "pull_request", + head_ref: head, + base_ref: base, + event: { + repository: { id: 1 }, + pull_request: { head: { repo: { id: headRepository } } }, + }, + }; +} + +for (const branch of ["main", "dev"]) { + test(`a push to ${branch} runs verify without a pull request payload`, () => { + assert.equal(runsVerify({ event_name: "push", ref: `refs/heads/${branch}` }), true); + }); +} + +test("only the internal dev-to-main promotion skips duplicate verification", () => { + assert.equal(runsVerify(pullRequest("dev", "main")), false); +}); + +for (const base of ["main", "dev"]) { + test(`a fork branch named dev targeting ${base} still runs verify`, () => { + assert.equal(runsVerify(pullRequest("dev", base, 2)), true); + }); + + test(`an ordinary feature PR targeting ${base} runs verify`, () => { + assert.equal(runsVerify(pullRequest("fix/example", base)), true); + }); +} + +test("a dev PR targeting another integration branch is not a production promotion", () => { + assert.equal(runsVerify(pullRequest("dev", "dev")), true); +}); diff --git a/logs/.d4add09e83cef3872faf6ff0dd90aa7b611e2105-audit.json b/logs/.d4add09e83cef3872faf6ff0dd90aa7b611e2105-audit.json deleted file mode 100644 index cd692d5..0000000 --- a/logs/.d4add09e83cef3872faf6ff0dd90aa7b611e2105-audit.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "keep": { - "days": true, - "amount": 14 - }, - "auditLog": "/Users/sbenson/Documents/GitHub/codeswhat-website/logs/.d4add09e83cef3872faf6ff0dd90aa7b611e2105-audit.json", - "files": [ - { - "date": 1765398359218, - "name": "/Users/sbenson/Documents/GitHub/codeswhat-website/logs/mcp-puppeteer-2025-12-10.log", - "hash": "ccd5a8481ee3f00d001daac2efdaadc8dd19a519295b269b8a05ac7d6a00b207" - } - ], - "hashType": "sha256" -} \ No newline at end of file diff --git a/logs/mcp-puppeteer-2025-12-10.log b/logs/mcp-puppeteer-2025-12-10.log deleted file mode 100644 index 80d5595..0000000 --- a/logs/mcp-puppeteer-2025-12-10.log +++ /dev/null @@ -1,2 +0,0 @@ -{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-12-10 15:25:59.282"} -{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-12-10 15:25:59.283"} diff --git a/start.sh b/start.sh deleted file mode 100755 index c2a90a6..0000000 --- a/start.sh +++ /dev/null @@ -1,260 +0,0 @@ -#!/bin/bash - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -MAGENTA='\033[0;35m' -NC='\033[0m' # No Color - -# Script configuration -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -PROJECT_DIR="$SCRIPT_DIR/frontend" -ENV_FILE="$PROJECT_DIR/.env.local" -ENV_EXAMPLE="$PROJECT_DIR/.env.local.example" - -# Process tracking -FRONTEND_PID="" - -# Display header -display_header() { - echo -e "${CYAN}╔═══════════════════════════════════════╗${NC}" - echo -e "${CYAN}║${NC} ${GREEN}🚀 Next.js Project Launcher${NC} ${CYAN}║${NC}" - echo -e "${CYAN}║${NC} ${BLUE}Development Server${NC} ${CYAN}║${NC}" - echo -e "${CYAN}╚═══════════════════════════════════════╝${NC}" - echo "" -} - -# Check if a port is in use -check_port() { - if lsof -Pi :$1 -sTCP:LISTEN -t >/dev/null 2>&1; then - return 0 - else - return 1 - fi -} - -# Cleanup function -cleanup() { - echo -e "\n${YELLOW}🛑 Shutting down development server...${NC}" - - # Kill frontend process - if [ ! -z "$FRONTEND_PID" ]; then - kill -TERM $FRONTEND_PID 2>/dev/null || true - fi - - # Kill any remaining processes on port 3000 - if check_port 3000; then - lsof -ti:3000 | xargs kill -9 2>/dev/null || true - fi - - echo -e "${GREEN}✅ Development server stopped${NC}" - exit -} - -# Set up trap to cleanup on exit -trap cleanup EXIT INT TERM - -# Function to check Node.js -check_node() { - echo -e "${BLUE}📋 Checking Node.js version...${NC}" - - if ! command -v node &> /dev/null; then - echo -e "${RED}❌ Node.js is not installed${NC}" - echo -e "${YELLOW}💡 Install Node.js 20 or higher from https://nodejs.org${NC}" - echo -e "${YELLOW}💡 Or use nvm: nvm install 20.9.0 && nvm use 20.9.0${NC}" - exit 1 - fi - - NODE_VERSION=$(node -v) - NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1 | cut -dv -f2) - - if [ $NODE_MAJOR -lt 20 ]; then - echo -e "${RED}❌ Node.js 20 or higher is required (found $NODE_VERSION)${NC}" - echo -e "${YELLOW}💡 Update Node.js from https://nodejs.org${NC}" - echo -e "${YELLOW}💡 Or use nvm: nvm install 20.9.0 && nvm use 20.9.0${NC}" - exit 1 - fi - - echo -e "${GREEN}✅ Found Node.js $NODE_VERSION${NC}" -} - -# Function to check npm -check_npm() { - echo -e "${BLUE}📦 Checking npm...${NC}" - - if ! command -v npm &> /dev/null; then - echo -e "${RED}❌ npm is not installed${NC}" - exit 1 - fi - - NPM_VERSION=$(npm -v) - echo -e "${GREEN}✅ Found npm $NPM_VERSION${NC}" -} - -# Function to setup environment -setup_environment() { - echo -e "\n${BLUE}🔧 Setting up environment...${NC}" - - cd "$PROJECT_DIR" - - # Check if .env.local exists - if [ ! -f "$ENV_FILE" ]; then - if [ -f "$ENV_EXAMPLE" ]; then - echo -e "${YELLOW} Creating .env.local from example...${NC}" - cp "$ENV_EXAMPLE" "$ENV_FILE" - echo -e "${GREEN}✅ Created .env.local - please update with your values${NC}" - else - echo -e "${YELLOW}⚠️ No .env.local file found${NC}" - echo -e "${YELLOW} Creating basic .env.local...${NC}" - cat > "$ENV_FILE" << EOF -# Site Configuration -NEXT_PUBLIC_SITE_NAME=CodesWhat -NEXT_PUBLIC_SITE_DESCRIPTION=Digital Solutions & Software Consulting -NEXT_PUBLIC_SITE_URL=http://localhost:3000 -EOF - echo -e "${GREEN}✅ Created basic .env.local${NC}" - fi - else - echo -e "${GREEN}✅ Environment file exists${NC}" - fi -} - -# Function to install dependencies -install_dependencies() { - echo -e "\n${BLUE}📦 Installing dependencies...${NC}" - - cd "$PROJECT_DIR" - - # Check if node_modules exists - if [ ! -d "node_modules" ]; then - echo -e "${YELLOW} Installing npm packages...${NC}" - npm install - if [ $? -ne 0 ]; then - echo -e "${RED}❌ Failed to install dependencies${NC}" - exit 1 - fi - else - echo -e "${YELLOW} Checking for updates...${NC}" - npm install --silent - fi - - echo -e "${GREEN}✅ Dependencies installed${NC}" -} - -# Function to start frontend -start_frontend() { - echo -e "\n${BLUE}🎨 Starting Next.js development server...${NC}" - - cd "$PROJECT_DIR" - - # Check if already running - if check_port 3000; then - echo -e "${YELLOW}⚠️ Port 3000 is already in use${NC}" - echo -e "${YELLOW} Killing existing process...${NC}" - lsof -ti:3000 | xargs kill -9 2>/dev/null || true - sleep 2 - fi - - # Start frontend with Turbopack - echo -e "${CYAN} Starting with Turbopack for fast refresh...${NC}" - npm run dev & - FRONTEND_PID=$! - - # Wait for the server to start - echo -e "${YELLOW} Waiting for server to start...${NC}" - for i in {1..30}; do - if check_port 3000; then - echo -e "${GREEN}✅ Development server started${NC}" - return 0 - fi - sleep 1 - done - - echo -e "${RED}❌ Failed to start development server${NC}" - exit 1 -} - -# Function to display status -display_status() { - echo -e "\n${GREEN}╔════════════════════════════════════════════════╗${NC}" - echo -e "${GREEN}║${NC} ✨ Development server is running! ${GREEN}║${NC}" - echo -e "${GREEN}╠════════════════════════════════════════════════╣${NC}" - echo -e "${GREEN}║${NC} ${BLUE}🌐 Local Server:${NC} http://localhost:3000 ${GREEN}║${NC}" - echo -e "${GREEN}║${NC} ${CYAN}⚡ Turbopack:${NC} Enabled for fast refresh ${GREEN}║${NC}" - echo -e "${GREEN}║${NC} ${MAGENTA}📱 Framework:${NC} Next.js 15.3.4 ${GREEN}║${NC}" - echo -e "${GREEN}║${NC} ${YELLOW}⚛️ React:${NC} Version 19 ${GREEN}║${NC}" - echo -e "${GREEN}╠════════════════════════════════════════════════╣${NC}" - echo -e "${GREEN}║${NC} ${YELLOW}Press Ctrl+C to stop the server${NC} ${GREEN}║${NC}" - echo -e "${GREEN}╚════════════════════════════════════════════════╝${NC}" -} - -# Function to open browser -open_browser() { - echo -e "\n${BLUE}🌐 Opening browser...${NC}" - - # Wait a moment for the server to be fully ready - sleep 2 - - # Detect OS and open browser - if [[ "$OSTYPE" == "darwin"* ]]; then - open http://localhost:3000 - elif [[ "$OSTYPE" == "linux-gnu"* ]]; then - xdg-open http://localhost:3000 2>/dev/null || echo -e "${YELLOW} Please open http://localhost:3000 manually${NC}" - else - echo -e "${YELLOW} Please open http://localhost:3000 in your browser${NC}" - fi -} - -# Function to show build info -show_build_info() { - echo -e "\n${CYAN}╔════════════════════════════════════════════════╗${NC}" - echo -e "${CYAN}║${NC} ${YELLOW}📊 Deployment Info${NC} ${CYAN}║${NC}" - echo -e "${CYAN}╠════════════════════════════════════════════════╣${NC}" - echo -e "${CYAN}║${NC} ${BLUE}Build:${NC} npm run build ${CYAN}║${NC}" - echo -e "${CYAN}║${NC} ${BLUE}Deploy:${NC} vercel --prod ${CYAN}║${NC}" - echo -e "${CYAN}║${NC} ${BLUE}Preview:${NC} vercel ${CYAN}║${NC}" - echo -e "${CYAN}╚════════════════════════════════════════════════╝${NC}" -} - -# Function to monitor output -monitor_output() { - echo -e "\n${CYAN}📺 Live server output:${NC}" - echo -e "${CYAN}════════════════════════════════════════════════${NC}" - - # Wait for the process - wait $FRONTEND_PID -} - -# Main execution -main() { - display_header - - # Check prerequisites - check_node - check_npm - - # Setup - setup_environment - install_dependencies - - # Start server - start_frontend - - # Display status - display_status - - # Show deployment info - show_build_info - - # Open browser - open_browser - - # Monitor process - monitor_output -} - -# Run main function -main \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/.gitignore b/visual-schedule-manager-portfolio/.gitignore deleted file mode 100644 index 763d40f..0000000 --- a/visual-schedule-manager-portfolio/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/README.md b/visual-schedule-manager-portfolio/README.md deleted file mode 100644 index b238bce..0000000 --- a/visual-schedule-manager-portfolio/README.md +++ /dev/null @@ -1,169 +0,0 @@ -# Visual Schedule Manager - -A modern, drag-and-drop visual scheduler component for managing global sync schedules across multiple timezones. Built with React, TypeScript, and Tailwind CSS. - -![Visual Schedule Manager Demo](./demo-screenshot.png) - -## Features - -### 🌍 Multi-Region Support -- Manage schedules for USA, Europe, and Asia regions -- Automatic timezone conversion and display -- Region-specific business hours overlay - -### ✨ Intuitive Drag & Drop -- Drag to enable/disable sync jobs -- Drag to reschedule to different time slots -- Visual feedback during drag operations -- Collision detection for overlapping schedules - -### 🤖 Smart Auto-Scheduling -- Intelligent optimization algorithm -- Avoids business hours automatically -- Maintains 1-hour spacing between syncs -- Prioritizes user syncs before project syncs - -### 🎯 Additional Features -- Real-time timezone conversion -- Business hours visualization -- Stacking support for overlapping schedules -- Current time indicator -- Responsive design - -## Tech Stack - -- **React 18** - UI framework -- **TypeScript** - Type safety -- **Tailwind CSS** - Styling -- **Framer Motion** - Animations -- **Zustand** - State management -- **date-fns** - Date utilities -- **Radix UI** - Accessible UI components - -## Component Structure - -``` -visual-schedule-manager/ -├── components/ -│ ├── schedules/ -│ │ ├── TimelineScheduler.tsx # Main scheduler component -│ │ ├── TimelineGrid.tsx # 24-hour grid with time slots -│ │ ├── TimelineTrack.tsx # Container for draggable items -│ │ ├── DraggableJobBadge.tsx # Draggable sync job badges -│ │ ├── TimezoneSelector.tsx # Timezone picker -│ │ ├── AutoScheduleDialog.tsx # Auto-scheduling interface -│ │ ├── BusinessHoursModal.tsx # Business hours configuration -│ │ └── ...other components -│ └── ui/ # Reusable UI components -├── utils/ # Helper functions -├── stores/ # State management -├── hooks/ # Custom React hooks -└── assets/ # Icons and images -``` - -## Integration - -### Basic Usage - -```tsx -import { TimelineScheduler } from './components/schedules/TimelineScheduler'; -import { SchedulerErrorBoundary } from './components/schedules/SchedulerErrorBoundary'; - -function App() { - return ( - - - - ); -} -``` - -### Required Assets - -The component requires integration icons located in `/assets/integrations/`: -- `fyle_favicon.png` - Destination system icon -- `onelogin_favicon.png` - User sync source icon -- `sage_favicon.png` - Project sync source icon - -### Styling Requirements - -The component uses Tailwind CSS. Ensure your project includes: -1. Tailwind CSS configuration -2. The required color palette (especially gray scales) -3. Dark mode support (optional) - -### State Management - -The scheduler uses Zustand for state management. The store is self-contained and doesn't require external setup. - -## API Integration - -The portfolio version includes a mock API client that simulates backend responses. For production use, replace the mock API client with your actual implementation. - -### Required API Endpoints - -```typescript -interface API { - getVisualSchedules(params: { timezone: string }): Promise - updateVisualScheduleTime(id: number, data: { position: number; timezone: string }): Promise - toggleVisualSchedule(id: number, enabled: boolean): Promise - autoSchedule(params: { timezone: string }): Promise -} -``` - -## Schedule Data Structure - -```typescript -interface Schedule { - id: number; - title: string; - entity: 'usa' | 'europe' | 'asia'; - syncType: 'user_sync' | 'project_sync'; - position: number | null; // Grid position (0-95 for 24 hours) - enabled: boolean; - startTimeUTC: string | null; - duration: number; // In minutes -} -``` - -## Customization - -### Timeline Configuration -- Each hour = 4 grid blocks (15-minute intervals) -- Block width: 60px (configurable in `timeline-helpers.ts`) -- Track height adjusts based on stacked items - -### Business Hours -- Default: 8 AM - 6 PM local time -- Configurable per region -- Visual overlay on timeline - -### Styling -- Uses Tailwind utility classes -- Customizable through CSS variables -- Dark mode support included - -## Browser Support - -- Chrome (latest) -- Firefox (latest) -- Safari (latest) -- Edge (latest) - -## Performance Considerations - -- Efficient drag-and-drop with minimal re-renders -- Memoized timezone calculations -- Optimized for up to 50 schedules per region - -## License - -MIT License - feel free to use in your projects! - -## Demo - -View the live demo at: [your-portfolio-url.com/visual-schedule-manager](#) - ---- - -Built with ❤️ for managing complex scheduling requirements across global teams. \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/assets/integrations/fyle_favicon.png b/visual-schedule-manager-portfolio/assets/integrations/fyle_favicon.png deleted file mode 100644 index add5be3..0000000 Binary files a/visual-schedule-manager-portfolio/assets/integrations/fyle_favicon.png and /dev/null differ diff --git a/visual-schedule-manager-portfolio/assets/integrations/onelogin_favicon.png b/visual-schedule-manager-portfolio/assets/integrations/onelogin_favicon.png deleted file mode 100644 index 8fd27cf..0000000 Binary files a/visual-schedule-manager-portfolio/assets/integrations/onelogin_favicon.png and /dev/null differ diff --git a/visual-schedule-manager-portfolio/assets/integrations/sage_favicon.png b/visual-schedule-manager-portfolio/assets/integrations/sage_favicon.png deleted file mode 100644 index 2d3ed80..0000000 Binary files a/visual-schedule-manager-portfolio/assets/integrations/sage_favicon.png and /dev/null differ diff --git a/visual-schedule-manager-portfolio/components/schedules/AutoScheduleDialog.tsx b/visual-schedule-manager-portfolio/components/schedules/AutoScheduleDialog.tsx deleted file mode 100644 index 23348ab..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/AutoScheduleDialog.tsx +++ /dev/null @@ -1,351 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '../ui/dialog'; -import { Button } from '../ui/button'; -import { Badge } from '../ui/badge'; -import { Separator } from '../ui/separator'; -import { Clock, CheckCircle, AlertTriangle, Loader2, ArrowRight } from 'lucide-react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { toast } from 'sonner'; -import { api } from '../../lib/api/client'; -import Image from 'next/image'; - -interface AutoScheduleDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -interface ProposedSchedule { - id: string; - title: string; - emoji: string; - entity: 'usa' | 'europe' | 'asia'; - syncType: 'project' | 'user'; - currentTime?: string; - proposedTime: string; - localTime: string; - reason: string; -} - -export function AutoScheduleDialog({ open, onOpenChange }: AutoScheduleDialogProps) { - const [isLoading, setIsLoading] = useState(false); - const [proposedSchedules, setProposedSchedules] = useState([]); - const [step, setStep] = useState<'preview' | 'generating' | 'success'>('preview'); - const [hasGenerated, setHasGenerated] = useState(false); - - // Generate proposed schedules when dialog opens - React.useEffect(() => { - if (open && step === 'preview' && !hasGenerated && !isLoading) { - generateProposedSchedules(); - } - }, [open, step, hasGenerated, isLoading]); - - const generateProposedSchedules = async () => { - if (isLoading) return; // Prevent concurrent calls - - setIsLoading(true); - setStep('generating'); - setHasGenerated(true); - - // Simulate delay for mock - setTimeout(() => { - // Mock proposals for each region - const mockProposals: ProposedSchedule[] = [ - // North America - { - id: '-1001', - title: 'User Sync', - emoji: '👤', - entity: 'usa', - syncType: 'user', - proposedTime: '2:00 AM EST', - localTime: '2:00 AM', - reason: 'Scheduled during off-peak hours to avoid business operations' - }, - { - id: '-1002', - title: 'Project Sync', - emoji: '📁', - entity: 'usa', - syncType: 'project', - proposedTime: '3:00 AM EST', - localTime: '3:00 AM', - reason: 'Runs after user sync to ensure user data is current' - }, - // Europe - { - id: '-2001', - title: 'User Sync', - emoji: '👤', - entity: 'europe', - syncType: 'user', - proposedTime: '2:00 AM GMT', - localTime: '2:00 AM', - reason: 'Scheduled during off-peak hours to avoid business operations' - }, - { - id: '-2002', - title: 'Project Sync', - emoji: '📁', - entity: 'europe', - syncType: 'project', - proposedTime: '3:00 AM GMT', - localTime: '3:00 AM', - reason: 'Runs after user sync to ensure user data is current' - }, - // Asia - { - id: '-3001', - title: 'User Sync', - emoji: '👤', - entity: 'asia', - syncType: 'user', - proposedTime: '2:00 AM JST', - localTime: '2:00 AM', - reason: 'Scheduled during off-peak hours to avoid business operations' - }, - { - id: '-3002', - title: 'Project Sync', - emoji: '📁', - entity: 'asia', - syncType: 'project', - proposedTime: '3:00 AM JST', - localTime: '3:00 AM', - reason: 'Runs after user sync to ensure user data is current' - } - ]; - - setProposedSchedules(mockProposals); - setStep('preview'); - setIsLoading(false); - }, 1500); - }; - - const handleApplySchedule = async () => { - setIsLoading(true); - - // Mock apply - just show success - setTimeout(() => { - setStep('success'); - toast.success(`Applied auto-schedule for ${proposedSchedules.length} syncs`); - - // Close dialog after success - setTimeout(() => { - onOpenChange(false); - setStep('preview'); - setProposedSchedules([]); - setHasGenerated(false); - }, 2000); - - setIsLoading(false); - }, 1000); - }; - - const handleCancel = () => { - onOpenChange(false); - setStep('preview'); - setProposedSchedules([]); - setHasGenerated(false); - }; - - return ( - - - - - - Auto-Schedule Optimization - - - Automatically optimize your sync schedules to run during off-peak hours with minimal conflicts. - - - - - {step === 'generating' && ( - - -

Generating Optimal Schedule...

-

- Analyzing your sync requirements and optimizing timing to minimize conflicts - and avoid business hours. -

-
- )} - - {step === 'preview' && ( - -
-
- -
-

- Schedule Optimization Rules -

-
    -
  • • User syncs scheduled before project syncs
  • -
  • • All syncs start at 2:00 AM local time
  • -
  • • 1-hour spacing between syncs to prevent conflicts
  • -
  • • Avoids business hours
  • -
-
-
-
- -
-

Proposed Schedule Changes

- - {proposedSchedules.map((schedule, index) => { - const sourceIcon = schedule.syncType === 'user' - ? '../../assets/integrations/onelogin_favicon.png' - : '../../assets/integrations/sage_favicon.png'; - - return ( - -
-
- - {schedule.entity === 'usa' && '🇺🇸'} - {schedule.entity === 'europe' && '🇬🇧'} - {schedule.entity === 'asia' && '🇯🇵'} - - -
-
- Source { - e.currentTarget.style.display = 'none'; - }} - /> -
- - - -
- Fyle { - e.currentTarget.style.display = 'none'; - }} - /> -
- - {schedule.title} -
- - - {schedule.entity === 'usa' && 'North America'} - {schedule.entity === 'europe' && 'Europe'} - {schedule.entity === 'asia' && 'Asia'} - -
- -
-
- {schedule.localTime} -
-
- {schedule.proposedTime} -
-
-
- - {schedule.currentTime && ( -
- Current: {schedule.currentTime} - → - New: {schedule.proposedTime} -
- )} - -

{schedule.reason}

-
- ); - })} -
-
- )} - - {step === 'success' && ( - - -

Schedule Applied Successfully!

-

- Your sync schedules have been optimized and updated. -

-
- )} -
- - - {step === 'preview' && ( - <> - - - - )} - - {step === 'generating' && ( - - )} - -
-
- ); -} \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/schedules/BusinessHoursModal.tsx b/visual-schedule-manager-portfolio/components/schedules/BusinessHoursModal.tsx deleted file mode 100644 index f03bc66..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/BusinessHoursModal.tsx +++ /dev/null @@ -1,291 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog'; -import { Button } from '../ui/button'; -import { Label } from '../ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; -import { Switch } from '../ui/switch'; -import { Clock, Building2 } from 'lucide-react'; -import { toast } from 'sonner'; - -interface BusinessHoursModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -interface BusinessHoursConfig { - usa: { start: number; end: number; enabled: boolean }; - europe: { start: number; end: number; enabled: boolean }; - asia: { start: number; end: number; enabled: boolean }; -} - -const DEFAULT_BUSINESS_HOURS: BusinessHoursConfig = { - usa: { start: 8, end: 18, enabled: true }, - europe: { start: 8, end: 18, enabled: true }, - asia: { start: 9, end: 18, enabled: true } -}; - -export function BusinessHoursModal({ open, onOpenChange }: BusinessHoursModalProps) { - const [config, setConfig] = useState(DEFAULT_BUSINESS_HOURS); - const [isDirty, setIsDirty] = useState(false); - - const formatHour = (hour: number): string => { - if (hour === 0) return '12:00 AM'; - if (hour === 12) return '12:00 PM'; - if (hour < 12) return `${hour}:00 AM`; - return `${hour - 12}:00 PM`; - }; - - const handleSave = () => { - // In a real implementation, this would save to backend/store - toast.success('Business hours configuration saved'); - setIsDirty(false); - onOpenChange(false); - }; - - const handleCancel = () => { - if (isDirty) { - if (confirm('You have unsaved changes. Are you sure you want to cancel?')) { - setConfig(DEFAULT_BUSINESS_HOURS); - setIsDirty(false); - onOpenChange(false); - } - } else { - onOpenChange(false); - } - }; - - const updateConfig = (entity: 'usa' | 'europe' | 'asia', field: keyof typeof config.usa, value: any) => { - setConfig(prev => ({ - ...prev, - [entity]: { - ...prev[entity], - [field]: value - } - })); - setIsDirty(true); - }; - - return ( - - - - - - Business Hours Configuration - - - -
- {/* USA Business Hours */} -
-
-

- 🇺🇸 - North America Business Hours - (Eastern Time) -

- updateConfig('usa', 'enabled', checked)} - /> -
- - {config.usa.enabled && ( -
-
- - -
- -
- - -
-
- )} -
- - {/* Europe Business Hours */} -
-
-

- 🇬🇧 - Europe Business Hours - (London Time) -

- updateConfig('europe', 'enabled', checked)} - /> -
- - {config.europe.enabled && ( -
-
- - -
- -
- - -
-
- )} -
- - {/* Asia Business Hours */} -
-
-

- 🇯🇵 - Asia Business Hours - (Tokyo Time) -

- updateConfig('asia', 'enabled', checked)} - /> -
- - {config.asia.enabled && ( -
-
- - -
- -
- - -
-
- )} -
- -
-
- -
-

About Business Hours

-
    -
  • • Business hours are shown as a shaded area on the timeline
  • -
  • • Scheduling during business hours will show a warning
  • -
  • • Times are in each region's local timezone
  • -
  • • Sync jobs should ideally run outside business hours
  • -
-
-
-
-
- - - - - -
-
- ); -} \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/schedules/BusinessHoursOverlay.tsx b/visual-schedule-manager-portfolio/components/schedules/BusinessHoursOverlay.tsx deleted file mode 100644 index d69f94c..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/BusinessHoursOverlay.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client" - -import React from 'react'; -import { getTimelinePosition, DEFAULT_BUSINESS_HOURS, TIME_BLOCK_WIDTH } from '../../utils/timeline-helpers'; -import { cn } from '../../lib/utils'; - -interface BusinessHoursOverlayProps { - entity: 'usa' | 'europe' | 'asia'; - className?: string; -} - -interface BusinessHours { - start: number; // position 0-95 - end: number; // position 0-95 - label: string; -} - -const getBusinessHours = (entity: 'usa' | 'europe' | 'asia'): BusinessHours[] => { - const config = DEFAULT_BUSINESS_HOURS[entity]; - - if (!config) { - return []; - } - - const startPos = getTimelinePosition(config.start, 0); - const endPos = getTimelinePosition(config.end, 0); - - return [{ - start: startPos, - end: endPos, - label: 'Business hours - preferred scheduling window' - }]; -}; - -export const BusinessHoursOverlay: React.FC = ({ - entity, - className -}) => { - const businessHours = getBusinessHours(entity); - - if (businessHours.length === 0) { - return null; - } - - return ( - <> - {businessHours.map((hours, index) => { - const leftPosition = hours.start * TIME_BLOCK_WIDTH; - const width = (hours.end - hours.start) * TIME_BLOCK_WIDTH; - - return ( -
- {/* Optional: Add a subtle pattern or border */} -
-
- ); - })} - - ); -}; diff --git a/visual-schedule-manager-portfolio/components/schedules/CurrentTimeIndicator.tsx b/visual-schedule-manager-portfolio/components/schedules/CurrentTimeIndicator.tsx deleted file mode 100644 index 6b01331..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/CurrentTimeIndicator.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client" - -import React, { useEffect, useState } from 'react'; -import { getTimelinePosition, TIME_BLOCK_WIDTH } from '../../utils/timeline-helpers'; -import { cn } from '../../lib/utils'; - -interface CurrentTimeIndicatorProps { - timezone: string; - className?: string; -} - -export const CurrentTimeIndicator: React.FC = ({ - timezone, - className -}) => { - const [position, setPosition] = useState(null); - const [currentTime, setCurrentTime] = useState(''); - - useEffect(() => { - const updatePosition = () => { - // Get current time in the specified timezone - const now = new Date(); - const options: Intl.DateTimeFormatOptions = { - timeZone: timezone, - hour: 'numeric', - minute: 'numeric', - hour12: false - }; - - const timeString = now.toLocaleString('en-US', options); - const [hours, minutes] = timeString.split(':').map(Number); - - // Calculate position based on current time - const timelinePosition = getTimelinePosition(hours, minutes); - setPosition(timelinePosition); - - // Format time for display - setCurrentTime( - now.toLocaleString('en-US', { - timeZone: timezone, - hour: 'numeric', - minute: '2-digit', - hour12: true - }) - ); - }; - - // Update immediately and then every minute - updatePosition(); - const interval = setInterval(updatePosition, 60000); - - return () => clearInterval(interval); - }, [timezone]); - - if (position === null) { - return null; - } - - const leftPosition = position * TIME_BLOCK_WIDTH; - - return ( -
- {/* Time label */} -
- {currentTime} -
-
- ); -}; diff --git a/visual-schedule-manager-portfolio/components/schedules/DragPreview.tsx b/visual-schedule-manager-portfolio/components/schedules/DragPreview.tsx deleted file mode 100644 index 3ea91d8..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/DragPreview.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import React from 'react'; -import { cn } from '../../lib/utils'; -import { JobBadgePreview } from './DraggableJobBadge'; -import type { DragItem } from '../../hooks/useDragAndDrop'; - -interface DragPreviewProps { - item: DragItem; - showTimePreview?: boolean; - previewPosition?: number; // Position where it will be dropped -} - -export const DragPreview: React.FC = ({ - item, - showTimePreview = false, - previewPosition, -}) => { - const formatTimeFromPosition = (position: number): string => { - const hours = Math.floor(position / 4); - const minutes = (position % 4) * 15; - - const formattedHours = hours.toString().padStart(2, '0'); - const formattedMinutes = minutes.toString().padStart(2, '0'); - - return `${formattedHours}:${formattedMinutes}`; - }; - - return ( -
- {/* Main badge preview */} -
- -
- - {/* Time preview tooltip */} - {showTimePreview && previewPosition !== undefined && ( -
-
- Drop at {formatTimeFromPosition(previewPosition)} -
-
-
-
-
- )} -
- ); -}; - -// Enhanced drag preview with queue information -interface EnhancedDragPreviewProps extends DragPreviewProps { - willQueue?: boolean; - queuedAfter?: string[]; - estimatedStartTime?: number; -} - -export const EnhancedDragPreview: React.FC = ({ - item, - showTimePreview = false, - previewPosition, - willQueue = false, - queuedAfter = [], - estimatedStartTime, -}) => { - const formatTimeFromPosition = (position: number): string => { - const hours = Math.floor(position / 4); - const minutes = (position % 4) * 15; - - const formattedHours = hours.toString().padStart(2, '0'); - const formattedMinutes = minutes.toString().padStart(2, '0'); - - return `${formattedHours}:${formattedMinutes}`; - }; - - return ( -
- {/* Main badge preview */} -
- -
- - {/* Enhanced preview with queue information */} - {showTimePreview && previewPosition !== undefined && ( -
-
- {willQueue ? ( -
-
- Will be queued -
-
- Scheduled: {formatTimeFromPosition(previewPosition)} -
- {estimatedStartTime !== undefined && estimatedStartTime !== previewPosition && ( -
- Estimated start: {formatTimeFromPosition(estimatedStartTime)} -
- )} - {queuedAfter.length > 0 && ( -
- After {queuedAfter.length} job{queuedAfter.length > 1 ? 's' : ''} -
- )} -
- ) : ( -
- Drop at {formatTimeFromPosition(previewPosition)} -
- )} - - {/* Tooltip arrow */} -
-
-
-
-
- )} -
- ); -}; - -// Simple drag preview for use in DragOverlay -export const SimpleDragPreview: React.FC<{ item: DragItem }> = ({ item }) => { - return ( -
- -
- ); -}; \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/schedules/DraggableJobBadge.tsx b/visual-schedule-manager-portfolio/components/schedules/DraggableJobBadge.tsx deleted file mode 100644 index 2753417..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/DraggableJobBadge.tsx +++ /dev/null @@ -1,207 +0,0 @@ -"use client" - -import React from 'react'; -import { Badge } from '../ui/badge'; -import { AlertCircle, ArrowRight } from 'lucide-react'; -import { cn } from '../../lib/utils'; -import { TIME_BLOCK_WIDTH } from '../../utils/timeline-helpers'; -import Image from 'next/image'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../ui/tooltip"; - -interface Schedule { - id: string; - title: string; - entity: string; // Now can be 'usa', 'europe', or 'asia' - syncType: string; // Can be 'user_sync' or 'project_sync' - emoji: string; - startTimeUTC: string; - duration: number; - enabled: boolean; - frequency: 'daily' | 'manual'; - position: number; - hasConflict?: boolean; - stackIndex?: number; // For handling overlapping schedules -} - -interface DraggableJobBadgeProps { - schedule: Schedule; - onDrag: (scheduleId: string) => void; - onDrop: (scheduleId: string, newPosition: number) => void; - isSelected?: boolean; - onSelect?: () => void; - isPositioned?: boolean; // Whether to use absolute positioning -} - -export const DraggableJobBadge: React.FC = ({ - schedule, - onDrag, - onDrop, - isSelected = false, - onSelect, - isPositioned = true -}) => { - const [isDragging, setIsDragging] = React.useState(false); - - const handleDragStart = (e: React.DragEvent) => { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('scheduleId', schedule.id); - setIsDragging(true); - onDrag(schedule.id); - }; - - const handleDragEnd = () => { - setIsDragging(false); - }; - - // Calculate position and width based on TIME_BLOCK_WIDTH - const leftPosition = schedule.position * TIME_BLOCK_WIDTH; - const width = schedule.duration * TIME_BLOCK_WIDTH; - - // Vertical offset for stacked schedules - const topOffset = schedule.stackIndex ? schedule.stackIndex * 36 : 4; - - // Determine source and destination icons - const getSourceIcon = () => { - if (schedule.syncType === 'user_sync' || schedule.title === 'User Sync') { - return '../../assets/integrations/onelogin_favicon.png'; - } - return '../../assets/integrations/sage_favicon.png'; - }; - - const getDestinationIcon = () => { - return '../../assets/integrations/fyle_favicon.png'; - }; - - return ( - - - -
- -
- {/* Source icon */} -
- Source { - e.currentTarget.style.display = 'none'; - }} - /> -
- - {/* Arrow */} - - - {/* Destination icon */} -
- Fyle { - e.currentTarget.style.display = 'none'; - }} - /> -
- - {/* Title */} - {schedule.title} - - {schedule.hasConflict && ( - - )} -
-
-
-
- -

{schedule.entity.toUpperCase()} - {schedule.title}

-

- {schedule.syncType === 'user_sync' ? 'OneLogin → Fyle' : 'Sage → Fyle'} -

-
-
-
- ); -}; - -// Simplified badge preview for drag operations -interface JobBadgePreviewProps { - id: string; - title: string; - emoji: string; - color?: string; - enabled: boolean; - hasConflict?: boolean; -} - -export const JobBadgePreview: React.FC = ({ - id, - title, - emoji, - color, - enabled, - hasConflict -}) => { - return ( - - {emoji} - {title} - {hasConflict && ( - - )} - - ); -}; diff --git a/visual-schedule-manager-portfolio/components/schedules/DroppableTimeSlot.tsx b/visual-schedule-manager-portfolio/components/schedules/DroppableTimeSlot.tsx deleted file mode 100644 index 42c2c32..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/DroppableTimeSlot.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react'; -import { useDroppable } from '@dnd-kit/core'; -import { cn } from '../../lib/utils'; - -interface DroppableTimeSlotProps { - slotId: string; - position: number; // 0-95 - isOccupied: boolean; -} - -export const DroppableTimeSlot: React.FC = ({ - slotId, - position, - isOccupied, -}) => { - const { - setNodeRef, - isOver, - } = useDroppable({ - id: slotId, - data: { - position, - isOccupied, - }, - }); - - return ( -
- {/* Visual indicator when hovering */} - {isOver && ( -
-
-
- )} - - {/* Accessibility - screen reader only content */} - - Drop zone for {formatTimeFromPosition(position)} - {isOccupied && ' (will be queued)'} - -
- ); -}; - -// Helper function to format time from position -const formatTimeFromPosition = (position: number): string => { - const hours = Math.floor(position / 4); - const minutes = (position % 4) * 15; - - const formattedHours = hours.toString().padStart(2, '0'); - const formattedMinutes = minutes.toString().padStart(2, '0'); - - return `${formattedHours}:${formattedMinutes}`; -}; - -// Component to render all drop zones for a timeline -interface TimelineDropZonesProps { - occupiedPositions: Set; - className?: string; -} - -export const TimelineDropZones: React.FC = ({ - occupiedPositions, - className, -}) => { - return ( -
- {Array.from({ length: 96 }, (_, index) => ( - - ))} -
- ); -}; \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/schedules/README.md b/visual-schedule-manager-portfolio/components/schedules/README.md deleted file mode 100644 index 5bfa770..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/README.md +++ /dev/null @@ -1,259 +0,0 @@ -# Drag & Drop System for Visual Scheduler - -This directory contains the drag-and-drop functionality for the visual scheduler, built by Agent B. The system provides horizontal-only dragging with grid snapping, collision detection, and queue warnings. - -## Components - -### `DraggableJobBadge.tsx` -The main draggable component representing sync jobs. - -```typescript -interface DraggableJobBadgeProps { - id: string; - title: string; - emoji: string; - color: string; // Tailwind color class (e.g., 'bg-blue-500') - enabled: boolean; - position: number; // 0-95 (15-minute blocks) - hasConflict?: boolean; -} -``` - -**Usage:** -```tsx - -``` - -**Visual States:** -- **Normal**: Full opacity with move cursor -- **Dragging**: 50% opacity with scale transformation -- **Disabled**: Gray background, no cursor interaction -- **Conflict**: Orange border with warning indicator - -### `DroppableTimeSlot.tsx` -Individual drop zones for 15-minute time blocks. - -```typescript -interface DroppableTimeSlotProps { - slotId: string; - position: number; // 0-95 - isOccupied: boolean; -} -``` - -**TimelineDropZones Component:** -Renders all 96 drop zones for a complete timeline. - -```tsx - -``` - -**Visual Feedback:** -- **Available slot**: Green background on drag over -- **Occupied slot**: Orange background on drag over (queue warning) - -### `DragPreview.tsx` -Custom drag preview components for enhanced UX. - -**SimpleDragPreview**: Basic preview for DragOverlay -```tsx - - {activeItem ? : null} - -``` - -**EnhancedDragPreview**: Advanced preview with queue information -```tsx - -``` - -## Hook: `useDragAndDrop` - -The main hook that provides drag-and-drop functionality using @dnd-kit. - -```typescript -const { - dndContextProps, - activeItem, - activeId, - isDragging, - dragOverPosition, - DndContext, - DragOverlay, -} = useDragAndDrop({ - onDragStart: (item) => console.log('Started dragging:', item), - onDragEnd: (result) => handleDragEnd(result), - snapToGrid: true, - gridSize: 20, // pixels -}); -``` - -**DragEndResult Interface:** -```typescript -interface DragEndResult { - itemId: string; - oldPosition: number; - newPosition: number; - willQueue: boolean; - queuedAfter?: string[]; -} -``` - -## Collision Detection Utilities - -### `checkCollision()` -```typescript -checkCollision( - newPosition: number, - duration: number, - existingSlots: TimeSlot[], - excludeId?: string -): boolean -``` - -### `getQueueInfo()` -```typescript -getQueueInfo( - position: number, - existingSlots: TimeSlot[] -): { willQueue: boolean; queuedAfter?: string[] } -``` - -### `findNextAvailableSlot()` -```typescript -findNextAvailableSlot( - startPosition: number, - duration: number, - existingSlots: TimeSlot[] -): number -``` - -### `calculateEstimatedStartTime()` -```typescript -calculateEstimatedStartTime( - position: number, - duration: number, - existingSlots: TimeSlot[] -): number -``` - -## Integration Example - -```tsx -'use client'; -import React, { useState } from 'react'; -import { - useDragAndDrop, - DraggableJobBadge, - TimelineDropZones, - SimpleDragPreview, - checkCollision, - getQueueInfo, - type DragEndResult, - type TimeSlot, -} from '@/components/settings/schedules'; - -export default function ScheduleTimeline() { - const [jobs, setJobs] = useState([ - { id: '1', title: 'USA Projects', emoji: '🇺🇸', position: 8, enabled: true }, - { id: '2', title: 'ADGM Users', emoji: '🇦🇪', position: 16, enabled: true }, - ]); - - const handleDragEnd = (result: DragEndResult) => { - setJobs(prev => prev.map(job => - job.id === result.itemId - ? { ...job, position: result.newPosition } - : job - )); - }; - - const { dndContextProps, activeItem, DndContext, DragOverlay } = useDragAndDrop({ - onDragEnd: handleDragEnd, - snapToGrid: true, - gridSize: 20, - }); - - const occupiedPositions = new Set(jobs.map(job => job.position)); - - return ( - -
- - - {jobs.map(job => ( - - ))} -
- - - {activeItem ? : null} - -
- ); -} -``` - -## Position System - -The system uses a 96-block timeline representing 24 hours: -- **Position 0**: 00:00 (midnight) -- **Position 4**: 01:00 -- **Position 8**: 02:00 -- **Position 95**: 23:45 - -Each position represents a 15-minute block. - -## Dependencies - -- `@dnd-kit/core`: Core drag-and-drop functionality -- `@dnd-kit/modifiers`: Horizontal axis restriction -- `@dnd-kit/utilities`: Additional utilities - -## Test Page - -Access the test harness at: `/settings/schedules/test-dnd` - -The test page includes: -- Sample draggable job badges -- Visual timeline with grid lines -- Drop zone feedback -- Collision detection logging -- Real-time position updates - -## Integration Points - -This system is designed to integrate with: -- **Agent A**: Timeline grid components -- **Agent C**: Timezone state management -- **Agent D**: Backend API for persistence -- **Agent E**: Main scheduler container - -The drag-and-drop system is framework-agnostic and can be easily integrated into any timeline layout provided by Agent A. \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/schedules/ScheduleDetailsModal.tsx b/visual-schedule-manager-portfolio/components/schedules/ScheduleDetailsModal.tsx deleted file mode 100644 index 8ee062e..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/ScheduleDetailsModal.tsx +++ /dev/null @@ -1,281 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog'; -import { Button } from '../ui/button'; -import { Badge } from '../ui/badge'; -import { Switch } from '../ui/switch'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; -import { Separator } from '../ui/separator'; -import { Clock, Settings, Trash2, Calendar, AlertTriangle } from 'lucide-react'; -import { motion } from 'framer-motion'; -import { toast } from 'sonner'; - -interface Schedule { - id: string; - title: string; - entity: 'usa' | 'europe' | 'asia'; - syncType: 'project' | 'user'; - emoji: string; - startTimeUTC?: string; - duration: number; - enabled: boolean; - frequency: 'daily' | 'manual'; - position?: number; - hasConflict?: boolean; -} - -interface ScheduleDetailsModalProps { - schedule: Schedule | null; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function ScheduleDetailsModal({ schedule, open, onOpenChange }: ScheduleDetailsModalProps) { - const [isEditing, setIsEditing] = useState(false); - const [editedSchedule, setEditedSchedule] = useState>({}); - - // Initialize edited schedule when modal opens - React.useEffect(() => { - if (schedule && open) { - setEditedSchedule(schedule); - setIsEditing(false); - } - }, [schedule, open]); - - if (!schedule) return null; - - const handleSave = async () => { - try { - // Simulate API call - await new Promise(resolve => setTimeout(resolve, 500)); - - toast.success('Schedule updated successfully'); - setIsEditing(false); - onOpenChange(false); - } catch (error) { - toast.error('Failed to update schedule'); - } - }; - - const handleDelete = async () => { - try { - // Simulate API call - await new Promise(resolve => setTimeout(resolve, 500)); - - toast.success('Schedule deleted successfully'); - onOpenChange(false); - } catch (error) { - toast.error('Failed to delete schedule'); - } - }; - - const formatTime = (timeUTC?: string) => { - if (!timeUTC) return 'Not scheduled'; - const [hours, minutes] = timeUTC.split(':').map(Number); - const date = new Date(); - date.setUTCHours(hours, minutes, 0, 0); - return date.toLocaleString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - timeZone: 'UTC' - }) + ' UTC'; - }; - - const getStatusBadge = () => { - if (!schedule.enabled) { - return Disabled; - } - if (schedule.hasConflict) { - return Conflict; - } - return Active; - }; - - return ( - - - - - {schedule.emoji} -
-
{schedule.title}
-
- {schedule.entity.toUpperCase()} • {schedule.syncType} sync -
-
-
-
- -
- {/* Status */} -
- Status - {getStatusBadge()} -
- - {/* Conflict Warning */} - {schedule.hasConflict && ( - -
- -
-
- Schedule Conflict -
-
- This sync will be queued after other jobs at the same time. -
-
-
-
- )} - - - - {/* Schedule Details */} -
-
-
- -
- - setEditedSchedule(prev => ({ ...prev, enabled: checked })) - } - disabled={!isEditing} - /> -
-
- -
- -
- {isEditing ? ( - - ) : ( -
- {schedule.frequency} -
- )} -
-
-
- -
- -
- {formatTime(schedule.startTimeUTC)} -
-
- -
- -
- ~{schedule.duration} minutes -
-
- -
- -
- Block {schedule.position || 0} of 96 (15-minute intervals) -
-
-
- - - - {/* Actions */} -
-
- - - Drag on timeline to reschedule - -
- -
- - - Use Auto-Schedule for optimization - -
-
-
- - - - -
- {isEditing ? ( - <> - - - - ) : ( - <> - - - - )} -
-
-
-
- ); -} \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/schedules/SchedulerErrorBoundary.tsx b/visual-schedule-manager-portfolio/components/schedules/SchedulerErrorBoundary.tsx deleted file mode 100644 index 6664255..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/SchedulerErrorBoundary.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client'; - -import React from 'react'; -import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; -import { Button } from '../ui/button'; -import { AlertTriangle, RefreshCw } from 'lucide-react'; - -interface SchedulerErrorBoundaryProps { - children: React.ReactNode; - fallback?: React.ReactNode; -} - -interface ErrorBoundaryState { - hasError: boolean; - error?: Error; -} - -export class SchedulerErrorBoundary extends React.Component< - SchedulerErrorBoundaryProps, - ErrorBoundaryState -> { - constructor(props: SchedulerErrorBoundaryProps) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error('Scheduler Error Boundary caught an error:', error, errorInfo); - } - - handleRetry = () => { - this.setState({ hasError: false, error: undefined }); - }; - - render() { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback; - } - - return ( -
- - - Error Loading Scheduler - -

- Failed to load the visual scheduler. This might be due to a temporary issue. -

-
- - -
-
-
-
- ); - } - - return this.props.children; - } -} \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/schedules/TimelineGrid.tsx b/visual-schedule-manager-portfolio/components/schedules/TimelineGrid.tsx deleted file mode 100644 index d6b162e..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/TimelineGrid.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client" - -import React from 'react'; -import { useMemo } from 'react'; -import { ScrollArea, ScrollBar } from '../ui/scroll-area'; -import { CurrentTimeIndicator } from './CurrentTimeIndicator'; -import { BusinessHoursOverlay } from './BusinessHoursOverlay'; -import { getGridColumns, formatTimeForDisplay } from '../../utils/timeline-helpers'; -import { cn } from '../../lib/utils'; - -// Constants for timeline dimensions -const TRACK_LABEL_WIDTH = 96; // Reduced since we removed labels -const TIMELINE_MIN_WIDTH = 2400; // Increased from 1200 to make blocks larger -const TOTAL_TIME_BLOCKS = 96; -const TIME_BLOCK_WIDTH = 25; // Increased from implicit ~12.5px to 25px - -interface TimelineGridProps { - timezone: string; - currentTimeZone?: string; - showBusinessHours?: boolean; - entity?: 'usa' | 'europe' | 'asia'; - children?: React.ReactNode; - className?: string; -} - -export const TimelineGrid: React.FC = ({ - timezone, - currentTimeZone, - showBusinessHours = false, - entity = 'usa', - children, - className -}) => { - // Generate hour markers for 24-hour period - const hourMarkers = Array.from({ length: 24 }, (_, index) => ({ - hour: index, - label: formatTimeForDisplay(index), - position: index * 4 // Each hour spans 4 blocks - })); - - return ( -
- {/* Grid container with fixed labels and scrollable timeline */} -
- {/* Fixed Labels Column */} -
- {/* Schedule header */} -
- Schedule -
- {/* Track labels container - will be filled by TimelineTrack components */} -
- {/* Labels will be absolutely positioned here by TimelineTrack */} -
-
- - {/* Scrollable Timeline */} -
-
- {/* Hour markers header */} -
- {hourMarkers.map(({ hour, label, position }) => ( -
- - {formatTimeForDisplay(hour)} - -
- ))} -
- - {/* Timeline content */} -
- {/* Grid background with 15-minute lines */} -
- {Array.from({ length: TOTAL_TIME_BLOCKS }).map((_, index) => ( -
- ))} -
- - {/* Business hours overlay */} - {showBusinessHours && entity && ( - - )} - - {/* Current time indicator - use currentTimeZone if provided, otherwise fall back to timezone */} - - - {/* Timeline tracks content */} -
- {children} -
-
-
-
-
-
- ); -}; diff --git a/visual-schedule-manager-portfolio/components/schedules/TimelineScheduler.tsx b/visual-schedule-manager-portfolio/components/schedules/TimelineScheduler.tsx deleted file mode 100644 index 1778127..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/TimelineScheduler.tsx +++ /dev/null @@ -1,743 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; -import { Button } from '../ui/button'; -import { Badge } from '../ui/badge'; -import { Clock, Settings, Calendar } from 'lucide-react'; -import { toast } from 'sonner'; -import { formatInTimeZone } from 'date-fns-tz'; - -// Components -import { TimelineGrid } from './TimelineGrid'; -import { TimelineTrack } from './TimelineTrack'; -import { TimezoneSelector } from './TimezoneSelector'; -import { AutoScheduleDialog } from './AutoScheduleDialog'; -import { BusinessHoursModal } from './BusinessHoursModal'; -import { DraggableJobBadge } from './DraggableJobBadge'; - -// API -import { api } from '../../lib/api/client'; - -interface Schedule { - id: number; - title: string; - emoji: string; - position: number | null; - startTimeUTC: string | null; - duration: number; - enabled: boolean; - frequency: string; - entity: string; - syncType: string; - description?: string; - stackIndex?: number; -} - -interface TimelineSchedulerProps { - className?: string; -} - -export const TimelineScheduler: React.FC = ({ className }) => { - const [schedules, setSchedules] = useState([]); - const [loading, setLoading] = useState(true); - const [autoScheduleOpen, setAutoScheduleOpen] = useState(false); - const [businessHoursOpen, setBusinessHoursOpen] = useState(false); - const [selectedTimezone, setSelectedTimezone] = useState('America/New_York'); - const [dropTarget, setDropTarget] = useState<{ position: number | null }>({ position: null }); - const [draggedScheduleId, setDraggedScheduleId] = useState(null); - - // Auto-detect user's timezone on mount - useEffect(() => { - const detected = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (detected) { - setSelectedTimezone(detected); - } - }, []); - - // Mock data for disabled syncs (for demonstration) - const getMockDisabledSyncs = (entity: string): Schedule[] => { - // Use negative IDs for mock data to avoid conflicts - const baseId = entity === 'usa' ? -1000 : entity === 'europe' ? -2000 : -3000; - return [ - { - id: baseId - 1, - title: 'User Sync', - entity: entity, - syncType: 'user_sync', - emoji: '👤', - enabled: false, - frequency: 'daily', - position: null, - startTimeUTC: null, - duration: 60, - description: 'Mock disabled user sync', - }, - { - id: baseId - 2, - title: 'Project Sync', - entity: entity, - syncType: 'project_sync', - emoji: '📁', - enabled: false, - frequency: 'daily', - position: null, - startTimeUTC: null, - duration: 60, - description: 'Mock disabled project sync', - } - ]; - }; - - // Load schedules - const loadSchedules = async () => { - try { - setLoading(true); - const response = await api.getVisualSchedules({ timezone: selectedTimezone }); - - // Ensure response is an array - if (response.data && Array.isArray(response.data)) { - let schedulesData = response.data.map(s => ({ - ...s, - position: s.position ?? null, - startTimeUTC: s.startTimeUTC ?? null - })); - - // Auto-schedule enabled syncs that don't have positions - schedulesData = autoScheduleEnabledSyncs(schedulesData); - - // For mockup: ensure we have disabled syncs for each entity - const entities = ['usa', 'europe', 'asia']; - entities.forEach(entity => { - const hasDisabledSyncs = schedulesData.some(s => - s.entity === entity && !s.enabled - ); - if (!hasDisabledSyncs) { - // Add mock disabled syncs - schedulesData.push(...getMockDisabledSyncs(entity)); - } - }); - - setSchedules(schedulesData); - } else { - // For mockup: create default disabled syncs - const mockSchedules = [ - ...getMockDisabledSyncs('usa'), - ...getMockDisabledSyncs('europe'), - ...getMockDisabledSyncs('asia') - ]; - setSchedules(mockSchedules); - } - } catch (error: any) { - console.error('Load schedules error:', error); - - // For mockup: use mock data on error - const mockSchedules = [ - ...getMockDisabledSyncs('usa'), - ...getMockDisabledSyncs('europe'), - ...getMockDisabledSyncs('asia') - ]; - setSchedules(mockSchedules); - } finally { - setLoading(false); - } - }; - - // Auto-schedule enabled syncs starting at 2am local time - const autoScheduleEnabledSyncs = (scheduleList: Schedule[]) => { - // Separate schedules by entity - const usaSchedules = scheduleList.filter(s => s.entity === 'usa'); - const europeSchedules = scheduleList.filter(s => s.entity === 'europe'); - const asiaSchedules = scheduleList.filter(s => s.entity === 'asia'); - - // Auto-schedule function for a group of schedules - const scheduleGroup = (group: Schedule[]) => { - return group.map(schedule => { - // Only auto-schedule if enabled and no position set - if (schedule.enabled && schedule.position === null) { - // User Sync at 2am, Project Sync at 3am - const hour = schedule.syncType === 'user_sync' ? 2 : 3; - const newSchedule = { ...schedule, position: hour * 4 }; // Convert hour to position - return newSchedule; - } - return schedule; - }); - }; - - // Schedule syncs for each region - const scheduledUsa = scheduleGroup(usaSchedules); - const scheduledEurope = scheduleGroup(europeSchedules); - const scheduledAsia = scheduleGroup(asiaSchedules); - - return [...scheduledUsa, ...scheduledEurope, ...scheduledAsia]; - }; - - useEffect(() => { - loadSchedules(); - }, [selectedTimezone]); - - // Handle schedule time update - const handleScheduleUpdate = async (scheduleId: number, newPosition: number) => { - try { - const result = await api.updateVisualScheduleTime(scheduleId, { - position: newPosition, - timezone: selectedTimezone - }); - - if (result.data.success) { - toast.success(result.data.queue_info ? result.data.queue_info.message : "Schedule time updated successfully"); - loadSchedules(); - } - } catch (error) { - toast.error('Failed to update schedule'); - } - }; - - // Handle drop from drag and drop - const handleDrop = (trackId: string, position: number) => { - // Get the schedule ID from the drag event - const scheduleId = parseInt(draggedScheduleId || '0'); - if (scheduleId) { - // Enable the sync and set its position - handleEnableSync(scheduleId, position); - setDraggedScheduleId(null); - } - }; - - // Handle enabling a sync and setting its position - const handleEnableSync = async (scheduleId: number, position: number) => { - // For mock data (negative IDs), just update state directly - if (scheduleId < 0) { - setSchedules(prev => prev.map(s => - s.id === scheduleId ? { ...s, enabled: true, position: position } : s - )); - toast.success("Schedule enabled and time updated"); - return; - } - - try { - // First enable the sync, then update its position - const schedule = schedules.find(s => s.id === scheduleId); - if (schedule && !schedule.enabled) { - // Update to enable - await api.toggleVisualSchedule(scheduleId, true); - } - - // Then update position - const result = await api.updateVisualScheduleTime(scheduleId, { - position: position, - timezone: selectedTimezone - }); - - if (result.data.success) { - toast.success("Schedule enabled and time updated"); - loadSchedules(); - } - } catch (error) { - toast.error('Failed to enable schedule'); - } - }; - - // Handle disabling a sync - const handleDisableSync = async (scheduleId: number) => { - // For mock data (negative IDs), just update state directly - if (scheduleId < 0) { - setSchedules(prev => prev.map(s => - s.id === scheduleId ? { ...s, enabled: false, position: null } : s - )); - toast.success("Schedule disabled"); - return; - } - - try { - const result = await api.toggleVisualSchedule(scheduleId, false); - - if (result.data.success) { - toast.success("Schedule disabled"); - loadSchedules(); - } - } catch (error) { - toast.error('Failed to disable schedule'); - } - }; - - // Get current time in different timezones - const getCurrentTimeForTimezone = (tz: string) => { - try { - return formatInTimeZone(new Date(), tz, 'h:mm a zzz'); - } catch { - return 'Invalid timezone'; - } - }; - - // Separate schedules by entity - const usaSchedules = schedules.filter(s => s.entity === 'usa'); - const europeSchedules = schedules.filter(s => s.entity === 'europe'); - const asiaSchedules = schedules.filter(s => s.entity === 'asia'); - - // Calculate stack indices for overlapping schedules - const calculateStackIndices = (scheduleList: Schedule[]) => { - // Only calculate for enabled schedules - const enabledSchedules = scheduleList.filter(s => s.enabled); - - // Sort by position - const sorted = [...enabledSchedules].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); - const schedulesWithStack = []; - - for (const schedule of sorted) { - let stackIndex = 0; - - // Check for overlaps with already processed schedules - for (const existing of schedulesWithStack) { - const schedStart = schedule.position ?? 0; - const schedEnd = schedStart + 4; // 1 hour duration - const existStart = existing.position ?? 0; - const existEnd = existStart + 4; - - // Check if they overlap and are on the same stack level - if (schedStart < existEnd && schedEnd > existStart && existing.stackIndex === stackIndex) { - stackIndex++; - } - } - - schedulesWithStack.push({ ...schedule, stackIndex }); - } - - return schedulesWithStack; - }; - - const usaSchedulesWithStack = calculateStackIndices(usaSchedules); - const europeSchedulesWithStack = calculateStackIndices(europeSchedules); - const asiaSchedulesWithStack = calculateStackIndices(asiaSchedules); - - // Calculate max stack depth for track height - const getMaxStackDepth = (scheduleList: any[]) => { - return Math.max(0, ...scheduleList.map(s => s.stackIndex || 0)); - }; - - const usaMaxStack = getMaxStackDepth(usaSchedulesWithStack); - const europeMaxStack = getMaxStackDepth(europeSchedulesWithStack); - const asiaMaxStack = getMaxStackDepth(asiaSchedulesWithStack); - - if (loading) { - return ( -
-
-
- ); - } - - // Show message if no schedules exist - if (schedules.length === 0 && !loading) { - return ( -
-
-
-

Visual Schedule Manager

-

- No schedules found. Click Auto-Schedule to create default schedules. -

-
- - -
- - - -

No Schedules Configured

-

- Get started by using auto-schedule to create optimal sync timings for all regions. -

-
- - { - setAutoScheduleOpen(open); - if (!open) { - loadSchedules(); - } - }} - /> -
- ); - } - - return ( -
- {/* Header Controls */} -
-
-

Visual Schedule Manager

-

- Drag and drop to schedule sync jobs. Times shown in your selected timezone. -

-

- ✓ Syncs on the timeline are enabled and will run at the scheduled time -

-
- -
- - - -
-
- - {/* USA Timeline */} - - -
-
-
- 🇺🇸 - North America Schedule -
- - {/* Disabled USA syncs */} -
{ - e.preventDefault(); - e.currentTarget.classList.add('border-red-400', 'bg-red-50'); - }} - onDragLeave={(e) => { - e.currentTarget.classList.remove('border-red-400', 'bg-red-50'); - }} - onDrop={(e) => { - e.preventDefault(); - e.currentTarget.classList.remove('border-red-400', 'bg-red-50'); - const scheduleId = parseInt(draggedScheduleId || '0'); - if (scheduleId) { - handleDisableSync(scheduleId); - setDraggedScheduleId(null); - } - }} - > - {usaSchedules.filter(s => !s.enabled).map(schedule => ( - { setDraggedScheduleId(id); }} - onDrop={() => {}} - isPositioned={false} - /> - ))} - {usaSchedules.filter(s => !s.enabled).length === 0 && ( -
- - - - Drag syncs here to disable -
- )} -
-
- - - - {getCurrentTimeForTimezone('America/New_York')} - -
-
- - - handleDrop('usa-syncs', position)} - > - {/* Render only enabled USA schedules on the timeline */} - {usaSchedulesWithStack.filter(s => s.enabled).map(schedule => ( - { setDraggedScheduleId(id); }} - onDrop={(id, pos) => handleScheduleUpdate(parseInt(id), pos)} - /> - ))} - - - -
- - {/* Europe Timeline */} - - -
-
-
- 🇬🇧 - Europe Schedule -
- - {/* Disabled Europe syncs */} -
{ - e.preventDefault(); - e.currentTarget.classList.add('border-red-400', 'bg-red-50'); - }} - onDragLeave={(e) => { - e.currentTarget.classList.remove('border-red-400', 'bg-red-50'); - }} - onDrop={(e) => { - e.preventDefault(); - e.currentTarget.classList.remove('border-red-400', 'bg-red-50'); - const scheduleId = parseInt(draggedScheduleId || '0'); - if (scheduleId) { - handleDisableSync(scheduleId); - setDraggedScheduleId(null); - } - }} - > - {europeSchedules.filter((s: Schedule) => !s.enabled).map((schedule: Schedule) => ( - { setDraggedScheduleId(id); }} - onDrop={() => {}} - isPositioned={false} - /> - ))} - {europeSchedules.filter((s: Schedule) => !s.enabled).length === 0 && ( -
- - - - Drag syncs here to disable -
- )} -
-
- - - - {getCurrentTimeForTimezone('Europe/London')} - -
-
- - - handleDrop('europe-syncs', position)} - > - {/* Render only enabled Europe schedules on the timeline */} - {europeSchedulesWithStack.filter((s: any) => s.enabled).map((schedule: any) => ( - { setDraggedScheduleId(id); }} - onDrop={(id, pos) => handleScheduleUpdate(parseInt(id), pos)} - /> - ))} - - - -
- - {/* Asia Timeline */} - - -
-
-
- 🇯🇵 - Asia Schedule -
- - {/* Disabled Asia syncs */} -
{ - e.preventDefault(); - e.currentTarget.classList.add('border-red-400', 'bg-red-50'); - }} - onDragLeave={(e) => { - e.currentTarget.classList.remove('border-red-400', 'bg-red-50'); - }} - onDrop={(e) => { - e.preventDefault(); - e.currentTarget.classList.remove('border-red-400', 'bg-red-50'); - const scheduleId = parseInt(draggedScheduleId || '0'); - if (scheduleId) { - handleDisableSync(scheduleId); - setDraggedScheduleId(null); - } - }} - > - {asiaSchedules.filter((s: Schedule) => !s.enabled).map((schedule: Schedule) => ( - { setDraggedScheduleId(id); }} - onDrop={() => {}} - isPositioned={false} - /> - ))} - {asiaSchedules.filter((s: Schedule) => !s.enabled).length === 0 && ( -
- - - - Drag syncs here to disable -
- )} -
-
- - - - {getCurrentTimeForTimezone('Asia/Tokyo')} - -
-
- - - handleDrop('asia-syncs', position)} - > - {/* Render only enabled Asia schedules on the timeline */} - {asiaSchedulesWithStack.filter((s: any) => s.enabled).map((schedule: any) => ( - { setDraggedScheduleId(id); }} - onDrop={(id, pos) => handleScheduleUpdate(parseInt(id), pos)} - /> - ))} - - - -
- - {/* Modals */} - { - setAutoScheduleOpen(open); - if (!open) { - // Reload schedules when dialog closes - loadSchedules(); - } - }} - /> - - -
- ); -}; \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/schedules/TimelineTrack.tsx b/visual-schedule-manager-portfolio/components/schedules/TimelineTrack.tsx deleted file mode 100644 index eee04ed..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/TimelineTrack.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client" - -import React, { useEffect, useRef } from 'react'; -import { cn } from '../../lib/utils'; -import { TIME_BLOCK_WIDTH } from '../../utils/timeline-helpers'; - -const TRACK_LABEL_WIDTH = 96; // Reduced since we removed labels - -interface TimelineTrackProps { - label: string; - emoji: string; - trackId: string; - children?: React.ReactNode; - className?: string; - maxStackDepth?: number; - onDrop?: (position: number) => void; -} - -export const TimelineTrack: React.FC = ({ - label, - emoji, - trackId, - children, - className, - maxStackDepth = 0, - onDrop -}) => { - const trackRef = useRef(null); - const labelRef = useRef(null); - - // Calculate height based on stack depth - // Base height is 40px (32px badge + 8px padding), add 36px for each additional stack level - const trackHeight = 40 + (maxStackDepth * 36); - const minHeight = `${trackHeight}px`; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - - if (!trackRef.current || !onDrop) return; - - // Get the position where the item was dropped - const rect = trackRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left; - const position = Math.floor(x / TIME_BLOCK_WIDTH); - - // Clamp position to valid range (0-95) - const clampedPosition = Math.max(0, Math.min(95, position)); - - onDrop(clampedPosition); - }; - - useEffect(() => { - // Position the label in the fixed column - if (trackRef.current && labelRef.current) { - const trackElement = trackRef.current; - const labelElement = labelRef.current; - - // Find the labels container - const labelsContainer = trackElement.closest('.grid')?.querySelector('.timeline-track-labels'); - if (labelsContainer && !labelsContainer.contains(labelElement)) { - labelsContainer.appendChild(labelElement); - } - } - }, []); - - return ( - <> - {/* Track label - rendered into the fixed column */} -
-
- {emoji && {emoji}} - {label && {label}} -
-
- - {/* Track content - rendered in the scrollable area */} -
- {children} -
- - ); -}; diff --git a/visual-schedule-manager-portfolio/components/schedules/TimezoneSelector.tsx b/visual-schedule-manager-portfolio/components/schedules/TimezoneSelector.tsx deleted file mode 100644 index 64ef775..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/TimezoneSelector.tsx +++ /dev/null @@ -1,144 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import { Clock } from 'lucide-react'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from '../ui/select'; -import { Badge } from '../ui/badge'; -import { cn } from '../../lib/utils'; - -export interface TimezoneSelectorProps { - value: string; - onChange: (timezone: string) => void; - className?: string; -} - -interface TimezoneOption { - value: string; - label: string; - abbreviation: string; - region: 'usa' | 'europe' | 'asia'; -} - -const TIMEZONE_GROUPS = { - usa: [ - { value: 'America/New_York', label: 'Eastern Time', abbreviation: 'EST/EDT' }, - { value: 'America/Chicago', label: 'Central Time', abbreviation: 'CST/CDT' }, - { value: 'America/Los_Angeles', label: 'Pacific Time', abbreviation: 'PST/PDT' }, - ], - europe: [ - { value: 'Europe/London', label: 'London', abbreviation: 'GMT/BST' }, - ], - asia: [ - { value: 'Asia/Tokyo', label: 'Tokyo', abbreviation: 'JST' }, - ], -}; - -export const TimezoneSelector = ({ - value, - onChange, - className -}: TimezoneSelectorProps) => { - - // Build the timezone options list with regions - const timezoneOptions = useMemo(() => { - const options: TimezoneOption[] = []; - - // Add USA timezones - TIMEZONE_GROUPS.usa.forEach(option => { - options.push({ - ...option, - region: 'usa' - }); - }); - - // Add Europe timezones - TIMEZONE_GROUPS.europe.forEach(option => { - options.push({ - ...option, - region: 'europe' - }); - }); - - // Add Asia timezones - TIMEZONE_GROUPS.asia.forEach(option => { - options.push({ - ...option, - region: 'asia' - }); - }); - - return options; - }, []); - - // Group options by region - const groupedOptions = useMemo(() => { - const groups: Record = { - usa: [], - europe: [], - asia: [] - }; - - timezoneOptions.forEach(option => { - groups[option.region].push(option); - }); - - return groups; - }, [timezoneOptions]); - - // Get current timezone display info - const currentTimezone = useMemo(() => { - return timezoneOptions.find(option => option.value === value); - }, [timezoneOptions, value]); - - const getCategoryLabel = (category: string) => { - switch (category) { - case 'usa': - return '🇺🇸 North America'; - case 'europe': - return '🇬🇧 Europe'; - case 'asia': - return '🇯🇵 Asia'; - default: - return category; - } - }; - - return ( -
- - -
- ); -}; diff --git a/visual-schedule-manager-portfolio/components/schedules/index.ts b/visual-schedule-manager-portfolio/components/schedules/index.ts deleted file mode 100644 index 55d2200..0000000 --- a/visual-schedule-manager-portfolio/components/schedules/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Export all schedule-related components -export { TimelineScheduler } from './TimelineScheduler'; -export { TimelineGrid } from './TimelineGrid'; -export { TimelineTrack } from './TimelineTrack'; -export { DraggableJobBadge } from './DraggableJobBadge'; -export { DroppableTimeSlot } from './DroppableTimeSlot'; -export { CurrentTimeIndicator } from './CurrentTimeIndicator'; -export { BusinessHoursOverlay } from './BusinessHoursOverlay'; -export { TimezoneSelector } from './TimezoneSelector'; -export { AutoScheduleDialog } from './AutoScheduleDialog'; -export { ScheduleDetailsModal } from './ScheduleDetailsModal'; -export { DragPreview } from './DragPreview'; -export { SchedulerErrorBoundary } from './SchedulerErrorBoundary'; -export { BusinessHoursModal } from './BusinessHoursModal'; - -// Export types if needed -export type { TimezoneSelectorProps } from './TimezoneSelector'; diff --git a/visual-schedule-manager-portfolio/components/ui/alert.tsx b/visual-schedule-manager-portfolio/components/ui/alert.tsx deleted file mode 100644 index 8ee604f..0000000 --- a/visual-schedule-manager-portfolio/components/ui/alert.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const alertVariants = cva( - "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", - { - variants: { - variant: { - default: "bg-background text-foreground", - destructive: - "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & VariantProps ->(({ className, variant, ...props }, ref) => ( -
-)) -Alert.displayName = "Alert" - -const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -AlertTitle.displayName = "AlertTitle" - -const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -AlertDescription.displayName = "AlertDescription" - -export { Alert, AlertTitle, AlertDescription } \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/ui/badge.tsx b/visual-schedule-manager-portfolio/components/ui/badge.tsx deleted file mode 100644 index 0205413..0000000 --- a/visual-schedule-manager-portfolio/components/ui/badge.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const badgeVariants = cva( - "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", - secondary: - "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", - destructive: - "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Badge({ - className, - variant, - asChild = false, - ...props -}: React.ComponentProps<"span"> & - VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span" - - return ( - - ) -} - -export { Badge, badgeVariants } diff --git a/visual-schedule-manager-portfolio/components/ui/button.tsx b/visual-schedule-manager-portfolio/components/ui/button.tsx deleted file mode 100644 index a2df8dc..0000000 --- a/visual-schedule-manager-portfolio/components/ui/button.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", - destructive: - "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot : "button" - - return ( - - ) -} - -export { Button, buttonVariants } diff --git a/visual-schedule-manager-portfolio/components/ui/card.tsx b/visual-schedule-manager-portfolio/components/ui/card.tsx deleted file mode 100644 index d05bbc6..0000000 --- a/visual-schedule-manager-portfolio/components/ui/card.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -function Card({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, -} diff --git a/visual-schedule-manager-portfolio/components/ui/dialog.tsx b/visual-schedule-manager-portfolio/components/ui/dialog.tsx deleted file mode 100644 index d9ccec9..0000000 --- a/visual-schedule-manager-portfolio/components/ui/dialog.tsx +++ /dev/null @@ -1,143 +0,0 @@ -"use client" - -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" - -import { cn } from "@/lib/utils" - -function Dialog({ - ...props -}: React.ComponentProps) { - return -} - -function DialogTrigger({ - ...props -}: React.ComponentProps) { - return -} - -function DialogPortal({ - ...props -}: React.ComponentProps) { - return -} - -function DialogClose({ - ...props -}: React.ComponentProps) { - return -} - -function DialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DialogContent({ - className, - children, - showCloseButton = true, - ...props -}: React.ComponentProps & { - showCloseButton?: boolean -}) { - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ) -} - -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - DialogTrigger, -} diff --git a/visual-schedule-manager-portfolio/components/ui/label.tsx b/visual-schedule-manager-portfolio/components/ui/label.tsx deleted file mode 100644 index 0f258cf..0000000 --- a/visual-schedule-manager-portfolio/components/ui/label.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client" - -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const labelVariants = cva( - "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" -) - -const Label = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps ->(({ className, ...props }, ref) => ( - -)) -Label.displayName = LabelPrimitive.Root.displayName - -export { Label } \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/ui/scroll-area.tsx b/visual-schedule-manager-portfolio/components/ui/scroll-area.tsx deleted file mode 100644 index 8e4fa13..0000000 --- a/visual-schedule-manager-portfolio/components/ui/scroll-area.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client" - -import * as React from "react" -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" - -import { cn } from "@/lib/utils" - -function ScrollArea({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - - - ) -} - -function ScrollBar({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -export { ScrollArea, ScrollBar } diff --git a/visual-schedule-manager-portfolio/components/ui/select.tsx b/visual-schedule-manager-portfolio/components/ui/select.tsx deleted file mode 100644 index dcbbc0c..0000000 --- a/visual-schedule-manager-portfolio/components/ui/select.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"use client" - -import * as React from "react" -import * as SelectPrimitive from "@radix-ui/react-select" -import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" - -import { cn } from "@/lib/utils" - -function Select({ - ...props -}: React.ComponentProps) { - return -} - -function SelectGroup({ - ...props -}: React.ComponentProps) { - return -} - -function SelectValue({ - ...props -}: React.ComponentProps) { - return -} - -function SelectTrigger({ - className, - size = "default", - children, - ...props -}: React.ComponentProps & { - size?: "sm" | "default" -}) { - return ( - - {children} - - - - - ) -} - -function SelectContent({ - className, - children, - position = "popper", - ...props -}: React.ComponentProps) { - return ( - - - - - {children} - - - - - ) -} - -function SelectLabel({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SelectItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} - -function SelectSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SelectScrollUpButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -function SelectScrollDownButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -export { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectScrollDownButton, - SelectScrollUpButton, - SelectSeparator, - SelectTrigger, - SelectValue, -} diff --git a/visual-schedule-manager-portfolio/components/ui/separator.tsx b/visual-schedule-manager-portfolio/components/ui/separator.tsx deleted file mode 100644 index 275381c..0000000 --- a/visual-schedule-manager-portfolio/components/ui/separator.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client" - -import * as React from "react" -import * as SeparatorPrimitive from "@radix-ui/react-separator" - -import { cn } from "@/lib/utils" - -function Separator({ - className, - orientation = "horizontal", - decorative = true, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { Separator } diff --git a/visual-schedule-manager-portfolio/components/ui/switch.tsx b/visual-schedule-manager-portfolio/components/ui/switch.tsx deleted file mode 100644 index bc4a038..0000000 --- a/visual-schedule-manager-portfolio/components/ui/switch.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client" - -import * as React from "react" -import * as SwitchPrimitives from "@radix-ui/react-switch" - -import { cn } from "@/lib/utils" - -const Switch = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -Switch.displayName = SwitchPrimitives.Root.displayName - -export { Switch } \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/components/ui/tooltip.tsx b/visual-schedule-manager-portfolio/components/ui/tooltip.tsx deleted file mode 100644 index 4ee26b3..0000000 --- a/visual-schedule-manager-portfolio/components/ui/tooltip.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client" - -import * as React from "react" -import * as TooltipPrimitive from "@radix-ui/react-tooltip" - -import { cn } from "@/lib/utils" - -function TooltipProvider({ - delayDuration = 0, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function Tooltip({ - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -function TooltipTrigger({ - ...props -}: React.ComponentProps) { - return -} - -function TooltipContent({ - className, - sideOffset = 0, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - - ) -} - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/visual-schedule-manager-portfolio/demo.tsx b/visual-schedule-manager-portfolio/demo.tsx deleted file mode 100644 index 3f477c6..0000000 --- a/visual-schedule-manager-portfolio/demo.tsx +++ /dev/null @@ -1,84 +0,0 @@ -'use client'; - -import React from 'react'; -import { TimelineScheduler } from './components/schedules/TimelineScheduler'; -import { SchedulerErrorBoundary } from './components/schedules/SchedulerErrorBoundary'; - -export default function VisualScheduleManagerDemo() { - return ( -
-
- {/* Header */} -
-

- Visual Schedule Manager -

-

- Drag-and-drop interface for managing global sync schedules across timezones -

-
- - {/* Feature highlights */} -
-
-
- 🌍 -

Multi-Region Support

-
-

- Manage schedules for USA, Europe, and Asia with automatic timezone conversion -

-
- -
-
- ✨ -

Drag & Drop

-
-

- Intuitive interface to enable, disable, and reschedule sync jobs -

-
- -
-
- 🤖 -

Auto-Schedule

-
-

- Intelligent optimization to avoid business hours and prevent conflicts -

-
-
- - {/* Main scheduler component */} -
- - - -
- - {/* Demo instructions */} -
-

- Demo Instructions -

-
    -
  • • Drag sync jobs from the disabled area onto the timeline to schedule them
  • -
  • • Drag scheduled jobs back to the disabled area to disable them
  • -
  • • Drag scheduled jobs to different time slots to reschedule
  • -
  • • Use the timezone selector to view schedules in different timezones
  • -
  • • Click "Auto-Schedule" to optimize all sync timings automatically
  • -
  • • Toggle "Business Hours" to see work hours overlay for each region
  • -
-
- - {/* Technical details */} -
-

Built with React, TypeScript, Tailwind CSS, and Framer Motion

-

Features: Drag & Drop, Timezone Management, Collision Detection, Auto-scheduling

-
-
-
- ); -} \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/hooks/useDragAndDrop.ts b/visual-schedule-manager-portfolio/hooks/useDragAndDrop.ts deleted file mode 100644 index 841987e..0000000 --- a/visual-schedule-manager-portfolio/hooks/useDragAndDrop.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { useState, useCallback } from 'react'; -import { - DndContext, - DragEndEvent, - DragStartEvent, - DragOverEvent, - useSensor, - useSensors, - PointerSensor, - KeyboardSensor, - closestCenter, - DragOverlay, -} from '@dnd-kit/core'; -import { - restrictToHorizontalAxis, -} from '@dnd-kit/modifiers'; - -export interface DragItem { - id: string; - title: string; - emoji: string; - color: string; - position: number; - enabled: boolean; - hasConflict?: boolean; -} - -export interface DragEndResult { - itemId: string; - oldPosition: number; - newPosition: number; - willQueue: boolean; - queuedAfter?: string[]; -} - -interface DragAndDropConfig { - onDragStart?: (item: DragItem) => void; - onDragEnd?: (result: DragEndResult) => void; - snapToGrid?: boolean; - gridSize?: number; // Width of one 15-minute block in pixels -} - -export const useDragAndDrop = (config: DragAndDropConfig) => { - const [activeId, setActiveId] = useState(null); - const [activeItem, setActiveItem] = useState(null); - const [dragOverPosition, setDragOverPosition] = useState(null); - - // Configure sensors for different input methods - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, // Require 8px movement before starting drag - }, - }), - useSensor(KeyboardSensor, { - // Enable keyboard navigation for accessibility - }) - ); - - const handleDragStart = useCallback((event: DragStartEvent) => { - const { active } = event; - const draggedItem = active.data.current as DragItem; - - setActiveId(active.id as string); - setActiveItem(draggedItem); - - config.onDragStart?.(draggedItem); - }, [config]); - - const handleDragOver = useCallback((event: DragOverEvent) => { - const { over } = event; - - if (over) { - // Extract position from droppable slot ID (format: "slot-{position}") - const position = parseInt(over.id.toString().replace('slot-', ''), 10); - setDragOverPosition(position); - } else { - setDragOverPosition(null); - } - }, []); - - const handleDragEnd = useCallback((event: DragEndEvent) => { - const { active, over } = event; - - if (over && activeItem) { - const newPosition = parseInt(over.id.toString().replace('slot-', ''), 10); - const oldPosition = activeItem.position; - - if (newPosition !== oldPosition) { - const result: DragEndResult = { - itemId: active.id as string, - oldPosition, - newPosition, - willQueue: false, // This will be calculated by the consumer - }; - - config.onDragEnd?.(result); - } - } - - // Reset drag state - setActiveId(null); - setActiveItem(null); - setDragOverPosition(null); - }, [activeItem, config]); - - const snapToGrid = useCallback((position: { x: number; y: number }) => { - if (!config.snapToGrid || !config.gridSize) { - return position; - } - - const snappedX = Math.round(position.x / config.gridSize) * config.gridSize; - return { - x: snappedX, - y: position.y, // Keep Y position unchanged - }; - }, [config.snapToGrid, config.gridSize]); - - const isDragging = activeId !== null; - - const dndContextProps = { - sensors, - collisionDetection: closestCenter, - modifiers: [restrictToHorizontalAxis], - onDragStart: handleDragStart, - onDragOver: handleDragOver, - onDragEnd: handleDragEnd, - }; - - return { - // State - activeId, - activeItem, - isDragging, - dragOverPosition, - - // Props for DndContext - dndContextProps, - - // Helper functions - snapToGrid, - - // Components (re-export for convenience) - DndContext, - DragOverlay, - }; -}; \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/hooks/useScheduleTimezone.ts b/visual-schedule-manager-portfolio/hooks/useScheduleTimezone.ts deleted file mode 100644 index c0df306..0000000 --- a/visual-schedule-manager-portfolio/hooks/useScheduleTimezone.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { useMemo, useCallback } from 'react'; -import { useScheduleStore, ScheduleWithPosition, ConflictInfo } from '../stores/schedule-store'; -import { - convertUTCToPosition, - convertPositionToUTC, - formatTimeForDisplay, - getCurrentTimePosition, - getBusinessHours -} from '../utils/timezone-conversion'; - -export const useScheduleTimezone = () => { - const { - selectedTimezone, - schedules, - businessHoursVisible - } = useScheduleStore(); - - // Convert all schedules to display timezone with positions - const schedulesInTimezone = useMemo(() => { - return schedules.map(schedule => { - const position = convertUTCToPosition(schedule.startTimeUTC, selectedTimezone); - const displayTime = formatTimeForDisplay(schedule.startTimeUTC, selectedTimezone); - - return { - ...schedule, - position, - displayTime - } as ScheduleWithPosition; - }); - }, [schedules, selectedTimezone]); - - // Get current time position for the selected timezone - const currentTimePosition = useMemo(() => { - return getCurrentTimePosition(selectedTimezone); - }, [selectedTimezone]); - - // Get business hours for entities in the selected timezone - const businessHours = useMemo(() => { - return { - usa: getBusinessHours('usa', selectedTimezone), - adgm: getBusinessHours('adgm', selectedTimezone) - }; - }, [selectedTimezone]); - - // Helper to convert UTC time to local display time - const convertTimeToLocal = useCallback((timeUTC: string) => { - return formatTimeForDisplay(timeUTC, selectedTimezone); - }, [selectedTimezone]); - - // Helper to convert position to UTC time - const convertPositionToTime = useCallback((position: number) => { - return convertPositionToUTC(position, selectedTimezone); - }, [selectedTimezone]); - - // Helper to convert UTC time to position - const convertTimeToPosition = useCallback((timeUTC: string) => { - return convertUTCToPosition(timeUTC, selectedTimezone); - }, [selectedTimezone]); - - // Get schedules by entity with timezone conversion - const getSchedulesByEntityInTimezone = useCallback((entity?: 'usa' | 'adgm') => { - return schedulesInTimezone.filter(schedule => - !entity || schedule.entity === entity - ); - }, [schedulesInTimezone]); - - // Get enabled schedules in timezone - const enabledSchedulesInTimezone = useMemo(() => { - return schedulesInTimezone.filter(schedule => schedule.enabled); - }, [schedulesInTimezone]); - - // Get conflicts with detailed information - const conflictsInTimezone = useMemo(() => { - const conflicts: ConflictInfo[] = []; - const positionGroups: { [position: number]: ScheduleWithPosition[] } = {}; - - // Group enabled schedules by position - enabledSchedulesInTimezone.forEach(schedule => { - const position = schedule.position; - if (!positionGroups[position]) { - positionGroups[position] = []; - } - positionGroups[position].push(schedule); - }); - - // Identify conflicts - Object.entries(positionGroups).forEach(([, schedules]) => { - if (schedules.length > 1) { - // Sort by priority (users first, then by entity) - const sortedSchedules = [...schedules].sort((a, b) => { - if (a.syncType !== b.syncType) { - return a.syncType === 'user' ? -1 : 1; - } - return a.entity.localeCompare(b.entity); - }); - - sortedSchedules.forEach((schedule, index) => { - if (index > 0) { // First schedule runs immediately, others are queued - conflicts.push({ - scheduleId: schedule.id, - conflictsWith: sortedSchedules.slice(0, index).map(s => s.id), - queuePosition: index + 1, - estimatedDelay: sortedSchedules.slice(0, index).reduce((sum, s) => sum + s.duration, 0) - }); - } - }); - } - }); - - return conflicts; - }, [enabledSchedulesInTimezone]); - - // Check if a specific time position has conflicts - const hasConflictAtPosition = useCallback((position: number, excludeId?: string) => { - const schedulesAtPosition = enabledSchedulesInTimezone.filter( - schedule => schedule.position === position && schedule.id !== excludeId - ); - return schedulesAtPosition.length > 0; - }, [enabledSchedulesInTimezone]); - - // Get queue information for a position - const getQueueInfoForPosition = useCallback((position: number, excludeId?: string) => { - const schedulesAtPosition = enabledSchedulesInTimezone.filter( - schedule => schedule.position === position && schedule.id !== excludeId - ); - - if (schedulesAtPosition.length === 0) { - return { willQueue: false }; - } - - return { - willQueue: true, - queuedAfter: schedulesAtPosition.map(s => s.title), - estimatedDelay: schedulesAtPosition.reduce((sum, s) => sum + s.duration, 0), - queuePosition: schedulesAtPosition.length + 1 - }; - }, [enabledSchedulesInTimezone]); - - // Check if a time is during business hours - const isBusinessHours = useCallback((position: number, entity: 'usa' | 'adgm') => { - const entityBusinessHours = businessHours[entity]; - return position >= entityBusinessHours.start && position <= entityBusinessHours.end; - }, [businessHours]); - - // Get next available position after a given position - const getNextAvailablePosition = useCallback((startPosition: number, entity?: 'usa' | 'adgm') => { - const position = startPosition; - - // Check up to 24 hours ahead - for (let i = 0; i < 96; i++) { - const checkPosition = (position + i) % 96; - - if (!hasConflictAtPosition(checkPosition)) { - // If entity is specified, prefer non-business hours - if (entity && isBusinessHours(checkPosition, entity)) { - continue; - } - return checkPosition; - } - } - - // Fallback: return a position even if it has conflicts - return (startPosition + 1) % 96; - }, [hasConflictAtPosition, isBusinessHours]); - - // Format time for display with timezone - const formatScheduleTime = useCallback((schedule: ScheduleWithPosition) => { - return { - utc: schedule.startTimeUTC, - local: schedule.displayTime, - position: schedule.position - }; - }, []); - - // Get timeline metadata for rendering - const timelineMetadata = useMemo(() => { - return { - timezone: selectedTimezone, - currentTimePosition, - businessHours, - businessHoursVisible, - totalSchedules: schedules.length, - enabledSchedules: enabledSchedulesInTimezone.length, - conflicts: conflictsInTimezone.length - }; - }, [ - selectedTimezone, - currentTimePosition, - businessHours, - businessHoursVisible, - schedules.length, - enabledSchedulesInTimezone.length, - conflictsInTimezone.length - ]); - - return { - // Core data - timezone: selectedTimezone, - schedules: schedulesInTimezone, - enabledSchedules: enabledSchedulesInTimezone, - conflicts: conflictsInTimezone, - - // Metadata - currentTimePosition, - businessHours, - timelineMetadata, - - // Helper functions - convertTimeToLocal, - convertPositionToTime, - convertTimeToPosition, - getSchedulesByEntityInTimezone, - hasConflictAtPosition, - getQueueInfoForPosition, - isBusinessHours, - getNextAvailablePosition, - formatScheduleTime, - - // Computed properties - hasConflicts: conflictsInTimezone.length > 0, - hasEnabledSchedules: enabledSchedulesInTimezone.length > 0 - }; -}; diff --git a/visual-schedule-manager-portfolio/lib/api/client.ts b/visual-schedule-manager-portfolio/lib/api/client.ts deleted file mode 100644 index ec40515..0000000 --- a/visual-schedule-manager-portfolio/lib/api/client.ts +++ /dev/null @@ -1,181 +0,0 @@ -// Mock API client for portfolio demo -// Simulates API calls without making real network requests - -interface VisualSchedule { - id: number; - title: string; - emoji: string; - position: number | null; - startTimeUTC: string | null; - duration: number; - enabled: boolean; - frequency: string; - entity: string; - syncType: string; - description?: string; -} - -// Mock data storage -let mockSchedules: VisualSchedule[] = [ - // USA schedules - { - id: 1, - title: 'User Sync', - emoji: '👤', - entity: 'usa', - syncType: 'user_sync', - enabled: true, - frequency: 'daily', - position: 8, // 2 AM = 2 * 4 blocks - startTimeUTC: '2024-01-01T07:00:00Z', - duration: 60, - description: 'Sync users from OneLogin to Fyle', - }, - { - id: 2, - title: 'Project Sync', - emoji: '📁', - entity: 'usa', - syncType: 'project_sync', - enabled: true, - frequency: 'daily', - position: 12, // 3 AM = 3 * 4 blocks - startTimeUTC: '2024-01-01T08:00:00Z', - duration: 60, - description: 'Sync projects from Sage to Fyle', - }, - // Europe schedules - { - id: 3, - title: 'User Sync', - emoji: '👤', - entity: 'europe', - syncType: 'user_sync', - enabled: false, - frequency: 'daily', - position: null, - startTimeUTC: null, - duration: 60, - description: 'Sync users from OneLogin to Fyle', - }, - { - id: 4, - title: 'Project Sync', - emoji: '📁', - entity: 'europe', - syncType: 'project_sync', - enabled: false, - frequency: 'daily', - position: null, - startTimeUTC: null, - duration: 60, - description: 'Sync projects from Sage to Fyle', - }, - // Asia schedules - { - id: 5, - title: 'User Sync', - emoji: '👤', - entity: 'asia', - syncType: 'user_sync', - enabled: false, - frequency: 'daily', - position: null, - startTimeUTC: null, - duration: 60, - description: 'Sync users from OneLogin to Fyle', - }, - { - id: 6, - title: 'Project Sync', - emoji: '📁', - entity: 'asia', - syncType: 'project_sync', - enabled: false, - frequency: 'daily', - position: null, - startTimeUTC: null, - duration: 60, - description: 'Sync projects from Sage to Fyle', - }, -]; - -// Helper to simulate async delay -const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - -// Mock API client -export const api = { - getVisualSchedules: async ({ timezone }: { timezone: string }) => { - await delay(300); // Simulate network delay - return { - data: [...mockSchedules], - success: true, - }; - }, - - updateVisualScheduleTime: async (scheduleId: number, data: { position: number; timezone: string }) => { - await delay(500); // Simulate network delay - - const schedule = mockSchedules.find(s => s.id === scheduleId); - if (schedule) { - schedule.position = data.position; - // Mock UTC time calculation - const hours = Math.floor(data.position / 4); - const minutes = (data.position % 4) * 15; - schedule.startTimeUTC = `2024-01-01T${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00Z`; - } - - return { - data: { - success: true, - schedule, - queue_info: { - message: `Schedule updated to ${Math.floor(data.position / 4)}:${((data.position % 4) * 15).toString().padStart(2, '0')}`, - }, - }, - }; - }, - - toggleVisualSchedule: async (scheduleId: number, enabled: boolean) => { - await delay(500); // Simulate network delay - - const schedule = mockSchedules.find(s => s.id === scheduleId); - if (schedule) { - schedule.enabled = enabled; - if (!enabled) { - schedule.position = null; - schedule.startTimeUTC = null; - } - } - - return { - data: { - success: true, - schedule, - }, - }; - }, - - autoSchedule: async (data: { timezone: string }) => { - await delay(1000); // Simulate processing time - - // Auto-schedule all syncs - mockSchedules.forEach(schedule => { - if (!schedule.enabled) { - schedule.enabled = true; - // User syncs at 2 AM, project syncs at 3 AM - const hour = schedule.syncType === 'user_sync' ? 2 : 3; - schedule.position = hour * 4; - schedule.startTimeUTC = `2024-01-01T${(hour + 5).toString().padStart(2, '0')}:00:00Z`; // Mock UTC - } - }); - - return { - data: { - success: true, - message: 'All schedules optimized successfully', - schedules: [...mockSchedules], - }, - }; - }, -}; \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/lib/utils.ts b/visual-schedule-manager-portfolio/lib/utils.ts deleted file mode 100644 index fcb6366..0000000 --- a/visual-schedule-manager-portfolio/lib/utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { type ClassValue, clsx } from "clsx" -import { twMerge } from "tailwind-merge" - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} - -export function formatUserName(createdBy: string): { display: string; tooltip: string } { - // Handle "Auto" case - if (createdBy === "Auto") { - return { display: "Auto", tooltip: "Automated sync" }; - } - - // Extract name and email from "Name (email)" format - const match = createdBy.match(/^(.+?)\s*\((.+?)\)$/); - - if (match) { - const [, fullName, email] = match; - const nameParts = fullName.trim().split(' '); - - if (nameParts.length >= 2) { - // Get first name and first initial of last name - const firstName = nameParts[0]; - const lastInitial = nameParts[nameParts.length - 1][0]; - return { - display: `${firstName} ${lastInitial}.`, - tooltip: `${fullName} (${email})` - }; - } else { - // Single name - return { - display: fullName, - tooltip: `${fullName} (${email})` - }; - } - } - - // Handle plain name without email - const nameParts = createdBy.trim().split(' '); - if (nameParts.length >= 2) { - const firstName = nameParts[0]; - const lastInitial = nameParts[nameParts.length - 1][0]; - return { - display: `${firstName} ${lastInitial}.`, - tooltip: createdBy - }; - } - - // Fallback - return { display: createdBy, tooltip: createdBy }; -} diff --git a/visual-schedule-manager-portfolio/package.json b/visual-schedule-manager-portfolio/package.json deleted file mode 100644 index 870e722..0000000 --- a/visual-schedule-manager-portfolio/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "visual-schedule-manager", - "version": "1.0.0", - "description": "A drag-and-drop visual scheduler for managing global sync schedules across timezones", - "main": "demo.tsx", - "author": "Your Name", - "license": "MIT", - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0", - "next": "^16.0.0", - "typescript": "^5.0.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@types/node": "^24.0.0", - "tailwindcss": "^4.0.0", - "clsx": "^2.0.0", - "tailwind-merge": "^3.0.0", - "framer-motion": "^12.0.0", - "lucide-react": "^0.559.0", - "date-fns": "^4.0.0", - "date-fns-tz": "^3.0.0", - "zustand": "^5.0.0", - "sonner": "^2.0.0", - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-label": "^2.0.2", - "@radix-ui/react-switch": "^1.0.3", - "@radix-ui/react-separator": "^1.0.3", - "@radix-ui/react-scroll-area": "^1.0.5", - "@radix-ui/react-tooltip": "^1.0.7", - "@radix-ui/react-alert-dialog": "^1.0.5", - "@radix-ui/react-slot": "^1.0.2", - "class-variance-authority": "^0.7.0" - }, - "devDependencies": { - "autoprefixer": "^10.4.16", - "postcss": "^8.4.31" - } -} \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/stores/schedule-store.ts b/visual-schedule-manager-portfolio/stores/schedule-store.ts deleted file mode 100644 index 4b0a6df..0000000 --- a/visual-schedule-manager-portfolio/stores/schedule-store.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import { convertUTCToPosition, getBusinessHours } from '../utils/timezone-conversion'; - -export interface Schedule { - id: string; - title: string; - entity: 'usa' | 'adgm'; - syncType: 'project' | 'user'; - emoji: string; - startTimeUTC: string; // HH:mm format - duration: number; // minutes - enabled: boolean; - frequency: 'daily' | 'manual'; -} - -export interface ScheduleWithPosition extends Schedule { - position: number; // 0-95 - displayTime: string; -} - -export interface ConflictInfo { - scheduleId: string; - conflictsWith: string[]; - queuePosition: number; - estimatedDelay: number; -} - -interface ScheduleStore { - // State - schedules: Schedule[]; - selectedTimezone: string; - businessHoursVisible: boolean; - recentTimezones: string[]; - - // Actions - setSchedules: (schedules: Schedule[]) => void; - addSchedule: (schedule: Schedule) => void; - updateSchedule: (id: string, updates: Partial) => void; - updateScheduleTime: (id: string, newTimeUTC: string) => void; - deleteSchedule: (id: string) => void; - setTimezone: (timezone: string) => void; - toggleBusinessHours: () => void; - addRecentTimezone: (timezone: string) => void; - - // Computed - getSchedulePosition: (schedule: Schedule) => number; - getSchedulesWithPositions: () => ScheduleWithPosition[]; - getConflicts: () => ConflictInfo[]; - getSchedulesByEntity: (entity?: 'usa' | 'adgm') => ScheduleWithPosition[]; - getEnabledSchedules: () => ScheduleWithPosition[]; -} - -// Auto-detect user's timezone -const getUserTimezone = (): string => { - try { - return Intl.DateTimeFormat().resolvedOptions().timeZone; - } catch { - return 'UTC'; - } -}; - -export const useScheduleStore = create()( - persist( - (set, get) => ({ - // Initial state - schedules: [], - selectedTimezone: getUserTimezone(), - businessHoursVisible: true, - recentTimezones: [], - - // Actions - setSchedules: (schedules) => set({ schedules }), - - addSchedule: (schedule) => set((state) => ({ - schedules: [...state.schedules, schedule] - })), - - updateSchedule: (id, updates) => set((state) => ({ - schedules: state.schedules.map(schedule => - schedule.id === id ? { ...schedule, ...updates } : schedule - ) - })), - - updateScheduleTime: (id, newTimeUTC) => set((state) => ({ - schedules: state.schedules.map(schedule => - schedule.id === id ? { ...schedule, startTimeUTC: newTimeUTC } : schedule - ) - })), - - deleteSchedule: (id) => set((state) => ({ - schedules: state.schedules.filter(schedule => schedule.id !== id) - })), - - setTimezone: (timezone) => { - const { addRecentTimezone } = get(); - addRecentTimezone(timezone); - set({ selectedTimezone: timezone }); - }, - - toggleBusinessHours: () => set((state) => ({ - businessHoursVisible: !state.businessHoursVisible - })), - - addRecentTimezone: (timezone) => set((state) => { - // Valid timezones for our system - const validTimezones = [ - 'America/New_York', - 'America/Chicago', - 'America/Denver', - 'America/Los_Angeles', - 'Asia/Dubai' - ]; - - // Only add if it's a valid timezone - if (!validTimezones.includes(timezone)) { - return state; - } - - const recentTimezones = [ - timezone, - ...state.recentTimezones.filter(tz => tz !== timezone && validTimezones.includes(tz)) - ].slice(0, 3); // Keep only 3 most recent and valid - - return { recentTimezones }; - }), - - // Computed functions - getSchedulePosition: (schedule) => { - const { selectedTimezone } = get(); - return convertUTCToPosition(schedule.startTimeUTC, selectedTimezone); - }, - - getSchedulesWithPositions: () => { - const { schedules, selectedTimezone } = get(); - return schedules.map(schedule => ({ - ...schedule, - position: convertUTCToPosition(schedule.startTimeUTC, selectedTimezone), - displayTime: schedule.startTimeUTC // This will be formatted by the component - })); - }, - - getConflicts: () => { - const schedulesWithPositions = get().getSchedulesWithPositions(); - const conflicts: ConflictInfo[] = []; - - // Group schedules by position - const positionGroups: { [position: number]: ScheduleWithPosition[] } = {}; - - schedulesWithPositions.forEach(schedule => { - if (!schedule.enabled) return; - - const position = schedule.position; - if (!positionGroups[position]) { - positionGroups[position] = []; - } - positionGroups[position].push(schedule); - }); - - // Find conflicts - Object.entries(positionGroups).forEach(([, schedules]) => { - if (schedules.length > 1) { - schedules.forEach((schedule, index) => { - if (index > 0) { // First schedule runs immediately, others are queued - conflicts.push({ - scheduleId: schedule.id, - conflictsWith: schedules.slice(0, index).map(s => s.id), - queuePosition: index + 1, - estimatedDelay: schedules.slice(0, index).reduce((sum, s) => sum + s.duration, 0) - }); - } - }); - } - }); - - return conflicts; - }, - - getSchedulesByEntity: (entity) => { - const schedulesWithPositions = get().getSchedulesWithPositions(); - if (!entity) return schedulesWithPositions; - return schedulesWithPositions.filter(schedule => schedule.entity === entity); - }, - - getEnabledSchedules: () => { - const schedulesWithPositions = get().getSchedulesWithPositions(); - return schedulesWithPositions.filter(schedule => schedule.enabled); - } - }), - { - name: 'schedule-store', - storage: createJSONStorage(() => localStorage), - // Only persist certain fields - partialize: (state) => ({ - schedules: state.schedules, - selectedTimezone: state.selectedTimezone, - businessHoursVisible: state.businessHoursVisible, - recentTimezones: state.recentTimezones, - }), - } - ) -); - -// Helper function to create a new schedule -export const createSchedule = ( - entity: 'usa' | 'adgm', - syncType: 'project' | 'user', - startTimeUTC?: string -): Omit => ({ - title: `${entity.toUpperCase()} ${syncType === 'project' ? 'Projects' : 'Users'}`, - entity, - syncType, - emoji: entity === 'usa' ? '🇺🇸' : '🇦🇪', - startTimeUTC: startTimeUTC || '02:00', // Default to 2 AM UTC - duration: 5, // Default 5 minutes - enabled: false, - frequency: 'daily' -}); - -// Helper to validate schedule time -export const validateScheduleTime = ( - timeUTC: string, - entity: 'usa' | 'adgm', - displayTimezone: string -): { valid: boolean; warning?: string } => { - try { - const position = convertUTCToPosition(timeUTC, displayTimezone); - const businessHours = getBusinessHours(entity, displayTimezone); - - // Check if the time falls within business hours - const isInBusinessHours = position >= businessHours.start && position <= businessHours.end; - - if (isInBusinessHours) { - return { - valid: true, - warning: 'Scheduling during business hours is not recommended as it may impact users.' - }; - } - - return { valid: true }; - } catch { - return { - valid: false, - warning: 'Invalid time format or timezone.' - }; - } -}; diff --git a/visual-schedule-manager-portfolio/tailwind.config.js b/visual-schedule-manager-portfolio/tailwind.config.js deleted file mode 100644 index 3818e5b..0000000 --- a/visual-schedule-manager-portfolio/tailwind.config.js +++ /dev/null @@ -1,77 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - darkMode: ["class"], - content: [ - './pages/**/*.{ts,tsx}', - './components/**/*.{ts,tsx}', - './app/**/*.{ts,tsx}', - './src/**/*.{ts,tsx}', - './*.{ts,tsx}', - ], - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { height: 0 }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: 0 }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - }, - }, - plugins: [require("tailwindcss-animate")], -} \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/tsconfig.json b/visual-schedule-manager-portfolio/tsconfig.json deleted file mode 100644 index ecf65ce..0000000 --- a/visual-schedule-manager-portfolio/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "paths": { - "@/*": ["./*"] - } - }, - "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] -} \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/utils/auto-schedule.ts b/visual-schedule-manager-portfolio/utils/auto-schedule.ts deleted file mode 100644 index deb98d1..0000000 --- a/visual-schedule-manager-portfolio/utils/auto-schedule.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Schedule } from '../stores/schedule-store'; -import { convertPositionToUTC, convertUTCToPosition } from './timezone-conversion'; - -export interface AutoScheduleOptions { - baseHour: number; // 2 for 2 AM - staggerMinutes: number; // 15 - syncOrder: ('user' | 'project')[]; -} - -export interface ScheduleUpdate { - scheduleId: string; - newTimeUTC: string; - displayTime: string; - timezone: string; - entity: 'usa' | 'adgm'; - syncType: 'project' | 'user'; -} - -export const generateAutoSchedule = ( - unscheduledSyncs: Schedule[], - options: AutoScheduleOptions = { - baseHour: 2, - staggerMinutes: 15, - syncOrder: ['user', 'project'] - } -): ScheduleUpdate[] => { - const scheduleUpdates: ScheduleUpdate[] = []; - - // Group syncs by entity - const syncsByEntity = unscheduledSyncs.reduce((groups, sync) => { - if (!groups[sync.entity]) { - groups[sync.entity] = {}; - } - if (!groups[sync.entity][sync.syncType]) { - groups[sync.entity][sync.syncType] = []; - } - groups[sync.entity][sync.syncType].push(sync); - return groups; - }, {} as Record>); - - // Process each entity - Object.entries(syncsByEntity).forEach(([entity, syncTypes]) => { - const entityTimezone = getLocalTimezone(entity as 'usa' | 'adgm'); - let currentMinuteOffset = 0; - - // Process sync types in order (users first, then projects) - options.syncOrder.forEach(syncType => { - if (syncTypes[syncType]) { - syncTypes[syncType].forEach(sync => { - // Calculate the scheduled time - const totalMinutes = (options.baseHour * 60) + currentMinuteOffset; - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - - // Create local time and convert to UTC - const localTimeString = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; - const position = convertUTCToPosition(localTimeString, entityTimezone); - const utcTimeString = convertPositionToUTC(position, entityTimezone); - - scheduleUpdates.push({ - scheduleId: sync.id, - newTimeUTC: utcTimeString, - displayTime: localTimeString, - timezone: entityTimezone, - entity: sync.entity, - syncType: sync.syncType - }); - - // Increment for next sync - currentMinuteOffset += options.staggerMinutes; - }); - } - }); - }); - - return scheduleUpdates; -}; - -export const getLocalTimezone = (entity: 'usa' | 'adgm'): string => { - switch (entity) { - case 'usa': - return 'America/New_York'; // EST/EDT - case 'adgm': - return 'Asia/Dubai'; // GST - default: - return 'UTC'; - } -}; - -export const validateScheduleTime = ( - timeUTC: string, - entity: 'usa' | 'adgm' -): { valid: boolean; warning?: string } => { - try { - const entityTimezone = getLocalTimezone(entity); - const position = convertUTCToPosition(timeUTC, entityTimezone); - - // Convert position back to hours for readability - const hours = Math.floor(position / 4); - - // Define business hours for each entity - const businessHours = { - usa: { start: 8, end: 20 }, // 8 AM to 8 PM - adgm: { start: 8, end: 18 } // 8 AM to 6 PM - }; - - const entityHours = businessHours[entity]; - const isInBusinessHours = hours >= entityHours.start && hours < entityHours.end; - - if (isInBusinessHours) { - return { - valid: true, - warning: `Scheduling during business hours (${entityHours.start}:00-${entityHours.end}:00 local time) may impact users.` - }; - } - - return { valid: true }; - } catch { - return { - valid: false, - warning: 'Invalid time format or timezone configuration.' - }; - } -}; - -export const calculateOptimalStartTime = ( - entity: 'usa' | 'adgm', - existingSchedules: Schedule[] = [] -): string => { - const entityTimezone = getLocalTimezone(entity); - const baseHour = 2; // 2 AM local time - - // Find occupied time slots - const occupiedPositions = existingSchedules - .filter(schedule => schedule.entity === entity && schedule.enabled) - .map(schedule => convertUTCToPosition(schedule.startTimeUTC, entityTimezone)); - - // Start from 2 AM and find first available slot - let currentPosition = baseHour * 4; // 2 AM = position 8 - - while (occupiedPositions.includes(currentPosition)) { - currentPosition += 1; // Move to next 15-minute slot - - // If we've gone past 6 AM, wrap to next day or return original time - if (currentPosition >= 24 * 4) { - break; - } - } - - return convertPositionToUTC(currentPosition, entityTimezone); -}; - -export const getRecommendedScheduleTimes = (entity: 'usa' | 'adgm'): string[] => { - const entityTimezone = getLocalTimezone(entity); - const recommendedHours = [2, 3, 4, 5]; // 2 AM to 5 AM local time - - return recommendedHours.map(hour => { - const position = hour * 4; // Convert hour to position - return convertPositionToUTC(position, entityTimezone); - }); -}; - -export const analyzeScheduleDistribution = (schedules: Schedule[]): { - usa: { user: number; project: number }; - adgm: { user: number; project: number }; - total: number; - unscheduled: number; -} => { - const analysis = { - usa: { user: 0, project: 0 }, - adgm: { user: 0, project: 0 }, - total: schedules.length, - unscheduled: 0 - }; - - schedules.forEach(schedule => { - if (!schedule.startTimeUTC || !schedule.enabled) { - analysis.unscheduled++; - } else { - analysis[schedule.entity][schedule.syncType]++; - } - }); - - return analysis; -}; - -export const generateSchedulePreview = ( - updates: ScheduleUpdate[] -): { - summary: string; - timeline: { time: string; entity: string; syncType: string; timezone: string }[]; - conflicts: string[]; -} => { - const timeline = updates - .map(update => ({ - time: update.displayTime, - entity: update.entity, - syncType: update.syncType, - timezone: update.timezone - })) - .sort((a, b) => a.time.localeCompare(b.time)); - - // Check for potential conflicts (same time slots) - const timeSlots: Record = {}; - timeline.forEach(item => { - const key = `${item.time}-${item.timezone}`; - if (!timeSlots[key]) timeSlots[key] = []; - timeSlots[key].push(item); - }); - - const conflicts = Object.entries(timeSlots) - .filter(([, items]) => items.length > 1) - .map(([time, items]) => - `${time}: ${items.map(i => `${i.entity} ${i.syncType}`).join(', ')}` - ); - - const summary = `Scheduling ${updates.length} sync jobs starting at 2:00 AM local time, staggered by 15 minutes.`; - - return { summary, timeline, conflicts }; -}; \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/utils/collision-detection.ts b/visual-schedule-manager-portfolio/utils/collision-detection.ts deleted file mode 100644 index 95bd532..0000000 --- a/visual-schedule-manager-portfolio/utils/collision-detection.ts +++ /dev/null @@ -1,110 +0,0 @@ -export interface TimeSlot { - id: string; - position: number; - duration: number; // in 15-minute blocks (usually 1) -} - -export const checkCollision = ( - newPosition: number, - duration: number, - existingSlots: TimeSlot[], - excludeId?: string -): boolean => { - // Check if the new position overlaps with existing slots - const newEndPosition = newPosition + duration - 1; - - return existingSlots.some(slot => { - if (excludeId && slot.id === excludeId) { - return false; // Exclude the item being dragged - } - - const slotEndPosition = slot.position + slot.duration - 1; - - // Check for overlap: new slot starts before existing slot ends - // and existing slot starts before new slot ends - return ( - newPosition <= slotEndPosition && - slot.position <= newEndPosition - ); - }); -}; - -export const findNextAvailableSlot = ( - startPosition: number, - duration: number, - existingSlots: TimeSlot[] -): number => { - // Find the next available position after startPosition - let currentPosition = startPosition; - const maxPosition = 96 - duration; // 96 total blocks minus duration - - while (currentPosition <= maxPosition) { - if (!checkCollision(currentPosition, duration, existingSlots)) { - return currentPosition; - } - currentPosition++; - } - - // If no slot available after start position, try from beginning - currentPosition = 0; - while (currentPosition < startPosition) { - if (!checkCollision(currentPosition, duration, existingSlots)) { - return currentPosition; - } - currentPosition++; - } - - // Return the original position if no available slot found - return startPosition; -}; - -export const getQueueInfo = ( - position: number, - existingSlots: TimeSlot[] -): { willQueue: boolean; queuedAfter?: string[] } => { - // Determine if this position will cause queuing - const conflictingSlots = existingSlots.filter(slot => { - const slotEndPosition = slot.position + slot.duration - 1; - return position >= slot.position && position <= slotEndPosition; - }); - - if (conflictingSlots.length === 0) { - return { willQueue: false }; - } - - // Sort conflicting slots by position to determine queue order - const sortedConflicts = conflictingSlots.sort((a, b) => a.position - b.position); - - return { - willQueue: true, - queuedAfter: sortedConflicts.map(slot => slot.id) - }; -}; - -export const calculateEstimatedStartTime = ( - position: number, - duration: number, - existingSlots: TimeSlot[] -): number => { - // Calculate when the job will actually start considering queue - const queueInfo = getQueueInfo(position, existingSlots); - - if (!queueInfo.willQueue) { - return position; - } - - // Find the latest end time of conflicting slots - const conflictingSlots = existingSlots.filter(slot => - queueInfo.queuedAfter?.includes(slot.id) - ); - - if (conflictingSlots.length === 0) { - return position; - } - - const latestEndTime = Math.max( - ...conflictingSlots.map(slot => slot.position + slot.duration) - ); - - return latestEndTime; -}; \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/utils/date.ts b/visual-schedule-manager-portfolio/utils/date.ts deleted file mode 100644 index dd43897..0000000 --- a/visual-schedule-manager-portfolio/utils/date.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Date formatting utilities for the frontend application - */ - -/** - * Format a date string to local time with consistent formatting - */ -export const formatLocalTime = ( - dateString: string, - options?: Intl.DateTimeFormatOptions -): string => { - const defaultOptions: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true - }; - - // Ensure UTC string format - const utcString = dateString.endsWith('Z') ? dateString : `${dateString}Z`; - const date = new Date(utcString); - - // Validate date - if (isNaN(date.getTime())) { - console.error(`Invalid date string: ${dateString}`); - return 'Invalid date'; - } - - return date.toLocaleString('en-US', options || defaultOptions); -}; - -/** - * Format a date string to local time with extended options (includes year and timezone) - */ -export const formatLocalTimeExtended = (dateString: string): string => { - return formatLocalTime(dateString, { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - timeZoneName: 'short' - }); -}; - -/** - * Format relative time (e.g., "2 hours ago", "just now") - */ -export const formatRelativeTime = (dateString: string): string => { - const date = new Date(dateString); - - // Validate date - if (isNaN(date.getTime())) { - console.error(`Invalid date string: ${dateString}`); - return 'Invalid date'; - } - - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - - // Handle future dates - if (diffMs < 0) { - return formatLocalTime(dateString); - } - - const diffSecs = Math.floor(diffMs / 1000); - const diffMins = Math.floor(diffSecs / 60); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffSecs < 60) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; - - return formatLocalTime(dateString); -}; - -/** - * Format duration in seconds to human-readable string - * Examples: 45 → "45s", 90 → "1m 30s", 3665 → "1h 1m" - */ -export const formatDuration = (seconds: number): string => { - // Validate input - if (seconds < 0 || !Number.isInteger(seconds)) { - console.error(`Invalid duration: ${seconds}. Duration must be a non-negative integer.`); - return '0s'; - } - - if (seconds < 60) return `${seconds}s`; - - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - - if (minutes < 60) { - return remainingSeconds > 0 - ? `${minutes}m ${remainingSeconds}s` - : `${minutes}m`; - } - - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - - return remainingMinutes > 0 - ? `${hours}h ${remainingMinutes}m` - : `${hours}h`; -}; \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/utils/timeline-helpers.ts b/visual-schedule-manager-portfolio/utils/timeline-helpers.ts deleted file mode 100644 index 3960ee5..0000000 --- a/visual-schedule-manager-portfolio/utils/timeline-helpers.ts +++ /dev/null @@ -1,74 +0,0 @@ -interface BusinessHoursConfig { - [key: string]: { start: number; end: number }; -} - -const DEFAULT_BUSINESS_HOURS: BusinessHoursConfig = { - usa: { start: 8, end: 18 }, - europe: { start: 8, end: 18 }, - asia: { start: 9, end: 18 } -}; - -export const TIME_BLOCK_WIDTH = 25; // Width of each 15-minute block in pixels - -export const getTimelinePosition = (hours: number, minutes: number): number => { - // Validate inputs - if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { - throw new Error('Invalid time: hours must be 0-23, minutes must be 0-59'); - } - - // Convert time to position (0-95) - // Each hour has 4 blocks (15-minute each) - // Clamp minutes to nearest 15-minute block - const minuteBlock = Math.floor(minutes / 15); - return hours * 4 + minuteBlock; -}; - -export function formatTimeForDisplay(hour: number, minute: number = 0): string { - const period = hour >= 12 ? 'PM' : 'AM'; - const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; - - if (minute === 0) { - return `${displayHour} ${period}`; - } - - return `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`; -} - -export const getGridColumns = (): string => { - // CSS grid template columns for 96 time blocks - return `repeat(96, ${TIME_BLOCK_WIDTH}px)`; -}; - -export const getTimeFromPosition = (position: number): { hours: number; minutes: number } => { - // Validate input - if (position < 0 || position > 95) { - throw new Error('Invalid position: must be between 0 and 95'); - } - - // Convert position back to hours and minutes - const hours = Math.floor(position / 4); - const minutes = (position % 4) * 15; - return { hours, minutes }; -}; - -export const isWithinBusinessHours = ( - position: number, - entity: 'usa' | 'europe' | 'asia', - businessHours: BusinessHoursConfig = DEFAULT_BUSINESS_HOURS -): boolean => { - // Check if position falls within business hours - // This is a simplified check - timezone-aware business hours - // will be handled by the BusinessHoursOverlay component - const { hours } = getTimeFromPosition(position); - - const config = businessHours[entity]; - return config ? hours >= config.start && hours < config.end : false; -}; - -export const calculateGridWidth = (blockWidth: number = TIME_BLOCK_WIDTH): number => { - // Calculate total grid width based on block width in pixels - return 96 * blockWidth; -}; - -// Export the business hours config for reuse -export { DEFAULT_BUSINESS_HOURS, type BusinessHoursConfig }; \ No newline at end of file diff --git a/visual-schedule-manager-portfolio/utils/timezone-conversion.ts b/visual-schedule-manager-portfolio/utils/timezone-conversion.ts deleted file mode 100644 index f16b8d3..0000000 --- a/visual-schedule-manager-portfolio/utils/timezone-conversion.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { toZonedTime, fromZonedTime, format } from 'date-fns-tz'; - -export const convertUTCToPosition = ( - timeUTC: string, // "HH:mm" - displayTimezone: string -): number => { - try { - // Parse the UTC time - const [hours, minutes] = timeUTC.split(':').map(Number); - - // Create a date object in UTC for today with the specified time - const utcDate = new Date(); - utcDate.setUTCHours(hours, minutes, 0, 0); - - // Convert to display timezone - const zonedDate = toZonedTime(utcDate, displayTimezone); - - // Get local hours and minutes - const localHours = zonedDate.getHours(); - const localMinutes = zonedDate.getMinutes(); - - // Convert to position (0-95) - each position is 15 minutes - return localHours * 4 + Math.floor(localMinutes / 15); - } catch (error) { - console.error('Error converting UTC to position:', error); - return 0; - } -}; - -export const convertPositionToUTC = ( - position: number, // 0-95 - displayTimezone: string -): string => { - try { - // Calculate hours and minutes from position - const hours = Math.floor(position / 4); - const minutes = (position % 4) * 15; - - // Create a date object in the display timezone for today - const localDate = new Date(); - localDate.setHours(hours, minutes, 0, 0); - - // Convert to UTC - const utcDate = fromZonedTime(localDate, displayTimezone); - - // Return in HH:mm format - return format(utcDate, 'HH:mm', { timeZone: 'UTC' }); - } catch (error) { - console.error('Error converting position to UTC:', error); - return '00:00'; - } -}; - -export const formatTimeForDisplay = ( - timeUTC: string, - displayTimezone: string -): string => { - try { - const [hours, minutes] = timeUTC.split(':').map(Number); - const utcDate = new Date(); - utcDate.setUTCHours(hours, minutes, 0, 0); - - const zonedDate = toZonedTime(utcDate, displayTimezone); - - // Format as "2:00 AM EST" - return format(zonedDate, 'h:mm a zzz', { timeZone: displayTimezone }); - } catch (error) { - console.error('Error formatting time for display:', error); - return timeUTC; - } -}; - -export const getTimezoneOffset = (timezone: string): number => { - try { - const now = new Date(); - const utcTime = now.getTime() + (now.getTimezoneOffset() * 60000); - const targetTime = new Date(utcTime + (getTimezoneOffsetInMs(timezone))); - return (targetTime.getTime() - now.getTime()) / (1000 * 60 * 60); - } catch (error) { - console.error('Error getting timezone offset:', error); - return 0; - } -}; - -const getTimezoneOffsetInMs = (timezone: string): number => { - const now = new Date(); - const zonedTime = toZonedTime(now, timezone); - const utcTime = fromZonedTime(zonedTime, 'UTC'); - return zonedTime.getTime() - utcTime.getTime(); -}; - -export const getBusinessHours = ( - entity: 'usa' | 'adgm', - displayTimezone: string -): { start: number; end: number } => { - try { - if (entity === 'usa') { - // USA: 8 AM EST to 8 PM PST - // Convert 8 AM EST to display timezone - const estStartUTC = convertPositionToUTC(32, 'America/New_York'); // 8 AM EST = position 32 - const pstEndUTC = convertPositionToUTC(80, 'America/Los_Angeles'); // 8 PM PST = position 80 - - const startPosition = convertUTCToPosition(estStartUTC, displayTimezone); - const endPosition = convertUTCToPosition(pstEndUTC, displayTimezone); - - return { start: startPosition, end: endPosition }; - } else { - // ADGM: 8 AM to 6 PM GST - const gstStartUTC = convertPositionToUTC(32, 'Asia/Dubai'); // 8 AM GST = position 32 - const gstEndUTC = convertPositionToUTC(72, 'Asia/Dubai'); // 6 PM GST = position 72 - - const startPosition = convertUTCToPosition(gstStartUTC, displayTimezone); - const endPosition = convertUTCToPosition(gstEndUTC, displayTimezone); - - return { start: startPosition, end: endPosition }; - } - } catch (error) { - console.error('Error calculating business hours:', error); - return { start: 32, end: 72 }; // Default 8 AM to 6 PM - } -}; - -export const getCurrentTimePosition = (timezone: string): number => { - try { - const now = new Date(); - const zonedNow = toZonedTime(now, timezone); - const hours = zonedNow.getHours(); - const minutes = zonedNow.getMinutes(); - - // Convert to position with sub-minute precision - return hours * 4 + (minutes / 15); - } catch (error) { - console.error('Error getting current time position:', error); - return 0; - } -}; - -export const getTimelinePosition = (hours: number, minutes: number): number => { - return hours * 4 + Math.floor(minutes / 15); -}; - -export const formatTimeMarker = (hour: number): string => { - return `${hour.toString().padStart(2, '0')}:00`; -}; - -export const getGridColumns = (): string => { - return 'repeat(96, 1fr)'; -}; \ No newline at end of file