danwel - Frontend Architecture

UI requirements, component specifications, and technology stack.

---

Table of Contents



1. [Technology Stack](#technology-stack)
2. [Calendar UI Requirements](#calendar-ui-requirements)
3. [Key Components](#key-components)
4. [Mobile & Tablet Support (Phase 2)](#mobile--tablet-support-phase-2)

---

Technology Stack

**Blade + Alpine.js + Tailwind CSS**

  • • Server-rendered with reactive islands

  • • SpinupWP friendly

  • • Progressive enhancement ready


  • | Technology | Purpose |
    |------------|---------|
    | **Blade** | Server-side templating |
    | **Alpine.js** | Lightweight reactive JavaScript |
    | **Tailwind CSS** | Utility-first CSS framework |
    | **interact.js** | Drag-and-drop functionality |

    ---

    Calendar UI Requirements



    Core Behavior



  • • **Horizontal scrollable calendar** (days scroll left/right)

  • • Vertical time axis (user's configured work hours)

  • • Configurable visible days (user preference: Mon-Fri, Mon-Sun, etc.)

  • • Smooth infinite scroll between weeks

  • • Snap to day boundaries


  • Interactions



  • • Drag-and-drop time blocks (move between days/times)

  • • Resize time blocks (change duration)

  • • Click empty space to create new block

  • • Click block to edit

  • • Touch-friendly for tablet/phone (Phase 2)


  • Visual Design



    | Element | Specification |
    |---------|---------------|
    | **danwel events** | Teal color (#0D9488) |
    | **External calendar events** | Original Google/Office365 colors |
    | **Current day** | Highlighted background |
    | **Current time** | Red indicator line |
    | **Summary row** | Hours per day and total week |

    Time Grid



  • • Respects user preferences:

  • - work_hours_start (default: 08:00)
    - work_hours_end (default: 18:00)
    - work_days (default: Mon-Fri)
  • • 15-minute or 30-minute snap intervals

  • • Hour markers on left axis
  • ---

    Key Components



    Organization Switcher



    ``javascript
    Alpine.data('orgSwitcher', () => ({
    organizations: [],
    current: null,

    async switch(orgId) {
    await fetch(
    /api/organizations/${orgId}/switch, { method: 'POST' });
    window.location.reload();
    }
    }));

    Calendar Component


    javascript
    Alpine.data('calendar', () => ({
    calendars: [], // enabled calendars
    events: [],
    scrollPosition: 0, // horizontal scroll state
    filters: {
    calendars: [], // selected calendar IDs
    users: [], // filter by team member
    },

    async loadEvents() {
    const params = new URLSearchParams({
    start: this.range.start,
    end: this.range.end,
    calendars: this.filters.calendars.join(','),
    });
    const response = await fetch(
    /api/time-blocks?${params});
    this.events = await response.json().data;
    },

    scrollToDay(day) {
    // Smooth scroll to specific day
    },

    scrollToToday() {
    // Center today in view
    }
    }));

    Time Block Component


    javascript
    Alpine.data('timeBlock', (block) => ({
    block: block,
    isDragging: false,
    isResizing: false,

    init() {
    this.setupDragAndDrop();
    },

    setupDragAndDrop() {
    // interact.js configuration
    },

    async save() {
    await fetch(
    /api/time-blocks/${this.block.id}, {
    method: 'PUT',
    body: JSON.stringify(this.block)
    });
    }
    }));

    Week Picker Component


    javascript
    Alpine.data('weekPicker', () => ({
    currentDate: new Date(),

    get weekStart() {
    // Calculate Monday of current week
    },

    get weekEnd() {
    // Calculate Sunday of current week
    },

    previousWeek() {
    this.currentDate.setDate(this.currentDate.getDate() - 7);
    this.loadEvents();
    },

    nextWeek() {
    this.currentDate.setDate(this.currentDate.getDate() + 7);
    this.loadEvents();
    },

    goToToday() {
    this.currentDate = new Date();
    this.loadEvents();
    }
    }));

    Summary Row


    javascript
    Alpine.data('summaryRow', () => ({
    hoursByDay: {},
    totalHours: 0,

    calculate(events) {
    // Only count danwel events (not external)
    const wpEvents = events.filter(e => e.source === 'weekplanner');

    this.hoursByDay = {};
    wpEvents.forEach(event => {
    const day = event.start_time.split('T')[0];
    const hours = this.calculateDuration(event);
    this.hoursByDay[day] = (this.hoursByDay[day] || 0) + hours;
    });

    this.totalHours = Object.values(this.hoursByDay)
    .reduce((sum, h) => sum + h, 0);
    }
    }));

    ---

    View Templates



    Layout Structure



    resources/views/
    ├── layouts/
    │ └── app.blade.php # Main layout with nav, org switcher
    ├── auth/
    │ ├── login.blade.php
    │ ├── register.blade.php
    │ └── invitation.blade.php
    ├── calendar/
    │ └── index.blade.php # Main calendar view
    ├── components/
    │ ├── calendar-grid.blade.php
    │ ├── org-switcher.blade.php
    │ ├── time-block.blade.php
    │ ├── week-picker.blade.php
    │ └── summary-row.blade.php
    ├── dashboard.blade.php
    ├── organizations/
    │ ├── create.blade.php
    │ └── settings.blade.php
    ├── settings/
    │ ├── index.blade.php
    │ ├── integrations.blade.php
    │ └── team.blade.php
    └── team/
    ├── index.blade.php
    └── invite.blade.php

    ---

    Mobile & Tablet Support (Phase 2)



    Approach Options



    | Option | Effort | UX | Offline |
    |--------|--------|-----|---------|
    | **Responsive PWA** | Low | Good | Basic (service worker) |
    | **React Native** | High | Excellent | Full |
    | **Flutter** | High | Excellent | Full |
    | **Capacitor (wrap web)** | Medium | Good | Partial |

    **Recommendation**: Start with **Responsive PWA** for Phase 2, consider native apps only if there's strong demand.

    PWA Features to Add


    javascript
    // manifest.json
    {
    "name": "danwel",
    "short_name": "danwel",
    "display": "standalone",
    "start_url": "/calendar",
    "theme_color": "#4F46E5",
    "icons": [...]
    }

    // Service worker for offline calendar viewing
    // Push notifications for reminders
    // Touch-optimized drag-drop

    API Ready for Mobile



    The REST API design already supports mobile clients:
  • • Token-based auth via Sanctum

  • • JSON responses with proper pagination

  • • Organization context via X-Organization-Id header

  • • Stateless design


  • UI Considerations for Touch



  • • Larger touch targets (44px minimum)

  • • Swipe gestures for week navigation

  • • Bottom sheet modals instead of centered

  • • Simplified calendar view for small screens
  • ---

    Prototype Reference

    The current prototype (calendar.php) contains working JavaScript that should be preserved:

    Working Features from Prototype



    | Feature | Implementation |
    |---------|----------------|
    | **loadBlocksFromGoogle()** | Convert Google events to local blocks array |
    | **renderBlocks()** | Render time blocks on calendar grid |
    | **setupDragAndResize()** | Initialize interact.js handlers |
    | **apiCreate/Update/Delete()** | API calls for CRUD operations |
    | **openModal()** | Edit/create modal dialog |
    | **updateSummary()** | Calculate hours per day/week |
    | **renderWeekPicker()** | Mini calendar for week selection |

    Color Mapping

    Google Calendar colorId to hex colors (from prototype):

    | colorId | Color Name | Hex |
    |---------|------------|-----|
    | 1 | Lavender | #7986cb |
    | 2 | Sage | #33b679 |
    | 3 | Grape | #8e24aa |
    | 4 | Flamingo | #e67c73 |
    | 5 | Banana | #f6c026 |
    | 6 | Tangerine | #f5511d |
    | 7 | Peacock | #039be5 |
    | 8 | Graphite | #616161 |
    | 9 | Blueberry | #3f51b5 |
    | 10 | Basil | #0b8043 |
    | 11 | Tomato | #d60000 |

    interact.js Configuration


    javascript
    interact('.time-block')
    .draggable({
    inertia: true,
    modifiers: [
    interact.modifiers.snap({
    targets: [/* 15-minute grid */],
    range: Infinity,
    relativePoints: [{ x: 0, y: 0 }]
    }),
    interact.modifiers.restrict({
    restriction: '.calendar-grid',
    endOnly: true
    })
    ],
    autoScroll: true,
    listeners: {
    move: dragMoveListener,
    end: dragEndListener
    }
    })
    .resizable({
    edges: { top: true, bottom: true },
    modifiers: [
    interact.modifiers.snap({
    targets: [/* 15-minute increments */]
    })
    ],
    listeners: {
    move: resizeMoveListener,
    end: resizeEndListener
    }
    });
    ``