Skill: claireaicodes/ms-forms-auto (version 1.0.0)

--- name: ms-forms-auto description: Automate Microsoft Forms submission with dual-calendar integration and M365 MFA support. Use when: automating daily productivity log submissions, submitting Microsoft Forms with calendar-based auto-fill, setting up scheduled form submissions with training/content-dev/learning hour calculations, or working with org MS Forms that require M365 number-matching MFA. Triggers on phrases like "fill out MS form", "automate form submission", "daily form report", "submit Microsoft form", "daily productivity log", "training hours form". --- # MS Forms Auto Automate Microsoft Forms submission with Playwright, dual-calendar auto-fill, and M365 number-matching MFA support. ## Quick Start ### 1. Install Dependencies ```bash cd <skill-directory> npm install npx playwright install chromium ``` ### 2. Set Up Credentials (one-time) ```bash node scripts/setup-credentials.js ``` Creates `config/credentials.json` (gitignored) with M365 email/password. ### 3. First Login with MFA ```bash xvfb-run --auto-servernum node scripts/mfa-login.js ``` When prompted, enter the 6-digit code from your Authenticator app. Saves auth state to `config/storageState.json` for headless reuse. ### 4. Fetch Calendar Data ```bash node scripts/calendar-fetch.js # Today (SGT) node scripts/calendar-fetch.js --date 2026-03-18 # Specific date ``` Returns JSON with all form fields auto-populated from both calendars. ### 5. Submit the Form ```bash node scripts/submit-daily.js ``` Reads today's entry from `daily-entries/YYYY-MM-DD.json` and submits. ## Architecture ### Dual Calendar System | Calendar | URL Source | Purpose | |----------|-----------|---------| | **Training (TMS)** | Built into script | Training Hours (source of truth) | | **Outlook** | Built into script | Content Dev Hours, Other Items | ### Field Logic | # | Field | Source | |---|-------|--------| | 1 | Date | Today (M/d/yyyy) | | 2 | Training Hours | Training calendar events | | 3 | Content Dev Hours | Outlook calendar (content dev blockouts) | | 4 | Content Dev Topic | Event description from Outlook | | 5 | Learning Hours | `max(0, 8 - training - contentDev)` | | 6 | Learning Topic | 2-3 random topics + any KT Sessions | | 7 | Other Items | "Preparation, testing, and rehearsal for my next class" + Outlook events (excludes KT Sessions, training duplicates) | | 8 | Team Hours | Blank | | 9 | Team Description | Blank | ### Fallback Defaults (if calendars fail) | Field | Fallback | |-------|----------| | Training Hours | 0 | | Content Dev Hours | 0 | | Content Dev Topic | NA | | Learning Hours | 8 | | Learning Topic | 3 random topics from pool | | Other Items | "Preparation, testing, and rehearsal for my next class" | ## Scripts | Script | Purpose | |--------|---------| | `scripts/calendar-fetch.js` | Fetch both calendars, output all form fields | | `scripts/submit-with-mfa.js` | **Primary**: Combined MFA login + form submit in one session | | `scripts/submit-daily.js` | Submit form using saved storageState (no MFA) | | `scripts/mfa-login.js` | Standalone MFA login (headed mode, saves state) | | `scripts/fill-form.js` | CLI form filler with `--date`, `--training`, etc. args | | `scripts/setup-credentials.js` | One-time credential setup | ## Automated Pipeline (via OpenClaw Cron) ``` 5:45 PM SGT → Pre-Fill cron (isolated) → calendar-fetch.js → draft entry JSON 6:00 PM SGT → Submit cron (main session) → asks Master for MFA code → submit-with-mfa.js --code CODE → login + submit in one session → done ``` **Why main session?** The submit job runs in the main session so it can ask Master Phil for the 6-digit Authenticator code. Speed is critical — the MFA code expires in ~30 seconds, so the login and submit happen in a single browser session via `submit-with-mfa.js`. ## Configuration Files | File | Purpose | Gitignored? | |------|---------|-------------| | `config/credentials.json` | M365 email + password | ✅ | | `config/storageState.json` | Saved browser session (cookies) | ✅ | | `config/calendars.json` | Calendar URLs (contains auth tokens) | ✅ | | `config/calendars.json.example` | Template for calendar URLs | ❌ | | `config/form-values.json` | Legacy form value defaults | ❌ | | `daily-entries/` | Daily submission audit trail | ✅ | ## MFA Handling (Improved) This skill now **intelligently handles both MFA and non-MFA scenarios**: ### Smart Behavior 1. **Auto-detection**: The script attempts credential login first, then checks if MFA is required 2. **Flexible invocation**: - `node scripts/submit-with-mfa.js` - Tries credentials only; if MFA appears, waits 30s for manual code entry or exits with code 2 - `node scripts/submit-with-mfa.js --code XXXXXX` - Provides MFA code upfront; uses it if MFA appears, otherwise proceeds 3. **Graceful fallback**: If credentials alone are sufficient (no MFA prompt), it works without requiring a code 4. **Exit codes**: - `0` — Success - `1` — General error / invalid credentials - `2` — MFA required but no code provided (use --code flag) - `3` — MFA code wrong or expired - `4` — Form submission failed ### Recommended Usage **For daily cron (6 PM SGT):** ``` node scripts/submit-with-mfa.js --code [MFA_CODE_HERE] ``` This ensures speed (code ready) and handles both cases: - If MFA appears → code is used immediately - If no MFA → code is ignored, submission proceeds **For manual testing (unknown MFA requirement):** ``` node scripts/submit-with-mfa.js ``` The script will wait 30 seconds on the MFA screen if needed, allowing you to manually type the code from your Authenticator app. ### Why This Works Microsoft's M365 authentication can be inconsistent: - Sometimes the OIDC cookie from previous login is still valid → no MFA needed - Sometimes the cookie expired → MFA required - Sometimes conditional access policies trigger MFA based on IP/device The new logic eliminates the "always ask for MFA" problem by making the code optional and only using it when the MFA prompt actually appears. ## Troubleshooting - **Auth expired**: Run `xvfb-run --auto-servernum node scripts/mfa-login.js` with a fresh Authenticator code - **Calendar fetch fails**: Script uses graceful fallbacks (0/NA defaults), form still submits - **Form URL changed**: Update the URL in `scripts/submit-daily.js` - **Wrong field values**: Run `node scripts/calendar-fetch.js --date YYYY-MM-DD` to verify calendar data - **Detailed test plan**: See `references/TESTING_AUTH_WORKFLOW.md` for comprehensive authentication workflow testing scenarios

path: config/form-structure.json
content: {
  "default": "daily-org-report",
  "forms": [
    {
      "name": "daily-org-report",
      "description": "Daily org activity logging form",
      "url": "https://forms.cloud.microsoft/r/LsxLaEv13i",
      "formId": [REDACTED_SECRET],
      "questions": [
        {
          "ordinal": 1,
          "label": "Date of Reporting",
          "questionId": [REDACTED_SECRET],
          "type": "date",
          "required": true
        },
        {
          "ordinal": 2,
          "label": "Training Hours",
          "questionId": [REDACTED_SECRET],
          "type": "number",
          "required": true
        },
        {
          "ordinal": 3,
          "label": "Content Dev Hours",
          "questionId": [REDACTED_SECRET],
          "type": "number",
          "required": true
        },
        {
          "ordinal": 4,
          "label": "Content Dev Topic",
          "questionId": [REDACTED_SECRET],
          "type": "text",
          "required": true
        },
        {
          "ordinal": 5,
          "label": "Learning Hours",
          "questionId": [REDACTED_SECRET],
          "type": "number",
          "required": true
        },
        {
          "ordinal": 6,
          "label": "Learning Topic",
          "questionId": [REDACTED_SECRET],
          "type": "text",
          "required": true
        },
        {
          "ordinal": 7,
          "label": "Other Items",
          "questionId": [REDACTED_SECRET],
          "type": "text",
          "required": false
        },
        {
          "ordinal": 8,
          "label": "Managing Team (Only for Leads)",
          "questionId": [REDACTED_SECRET],
          "type": "number",
          "required": false
        },
        {
          "ordinal": 9,
          "label": "Managing Team Description",
          "questionId": [REDACTED_SECRET],
          "type": "text",
          "required": false
        }
      ]
    }
  ]
}


path: config/form-values.json
content: {
  "daily-org-report": {
    "defaults": {
      "1": "TODAY",
      "2": "0",
      "3": "0",
      "4": "NA",
      "5": "0",
      "6": "NA",
      "7": "",
      "8": "",
      "9": ""
    },
    "perDate": {
      "2026-03-18": {
        "2": "4",
        "3": "2",
        "4": "AI Fundamentals - Modules 1-3",
        "5": "1",
        "6": "Web3 DeFi protocols"
      }
    }
  }
}


path: package-lock.json
content: {
  "name": "ms-forms-auto",
  "version": "0.1.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "ms-forms-auto",
      "version": "0.1.0",
      "dependencies": {
        "playwright": "^1.58.0"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/fsevents": {
      "version": "2.3.2",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "hasInstallScript": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
    "node_modules/playwright": {
      "version": "1.58.2",
      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "playwright-core": "1.58.2"
      },
      "bin": {
        "playwright": "cli.js"
      },
      "engines": {
        "node": ">=18"
      },
      "optionalDependencies": {
        "fsevents": "2.3.2"
      }
    },
    "node_modules/playwright-core": {
      "version": "1.58.2",
      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "bin": {
        "playwright-core": "cli.js"
      },
      "engines": {
        "node": ">=18"
      }
    }
  }
}


path: package.json
content: {
  "name": "ms-forms-auto",
  "version": "0.1.0",
  "description": "OpenClaw skill for automating Microsoft Forms submission with M365 auto-login via Playwright",
  "private": true,
  "main": "scripts/submit.js",
  "scripts": {
    "setup": "node scripts/setup-credentials.js",
    "submit": "node scripts/submit.js",
    "dry-run": "node scripts/submit.js --dry-run",
    "headed": "node scripts/submit.js --headed"
  },
  "dependencies": {
    "playwright": "^1.58.0"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}


path: references/TESTING_AUTH_WORKFLOW.md
content: # Authentication Workflow Fix - Testing & Verification

## Problem
The original `submit-with-mfa.js` script **always required** an MFA code, even when the login could succeed with just username and password. This was inflexible and caused failures when MFA wasn't actually needed.

## Solution Implemented
Rewrote the script with **smart authentication** that:
1. Auto-detects whether MFA is required after credentials entry
2. Accepts optional `--code` argument (only used if MFA appears)
3. Handles both scenarios gracefully:
   - **No MFA needed**: Credentials alone suffice → submits directly
   - **MFA needed**: Uses provided code (if any) or waits 30s for manual entry

## Testing Plan

### Prerequisites
- ✅ Skill installed at `~/.openclaw/skills/ms-forms-auto`
- ✅ `config/credentials.json` exists with valid M365 email/password
- ✅ `config/storageState.json` may exist (cached session) or be absent
- ✅ `daily-entries/2026-03-20.json` exists (backfilled entry for testing)

---

### Test Case 1: Credential-Only Login (No MFA)
**Scenario:** Storage state is invalid/absent, and this particular login does NOT trigger MFA.

**Steps:**
```bash
cd /home/ubuntu/.openclaw/workspace/skills/ms-forms-auto
node scripts/submit-with-mfa.js --date 2026-03-20
```

**Expected Behavior:**
1. Browser opens (headed via xvfb)
2. Navigates to form URL → redirects to login.microsoftonline.com
3. Enters email and password automatically
4. **No MFA prompt appears** (or disappears quickly)
5. Redirects to form page
6. Fills March 20 entry from `daily-entries/2026-03-20.json`
7. Submits form successfully
8. Exit code: `0`
9. Auth state saved to `config/storageState.json`

**Success Criteria:** Form submits without requiring any MFA code interaction.

---

### Test Case 2: MFA Required - Code Provided
**Scenario:** Storage state invalid, and MFA is triggered. Code provided via `--code`.

**Steps:**
```bash
cd /home/ubuntu/.openclaw/workspace/skills/ms-forms-auto
node scripts/submit-with-mfa.js --code 123456 --date 2026-03-20
```

**Expected Behavior:**
1. Browser opens
2. Enters email and password
3. Detects MFA input field
4. Enters the provided 6-digit code
5. Clicks submit on MFA screen
6. Handles "Stay signed in?" prompt (clicks Yes)
7. Redirects to form
8. Fills and submits March 20 entry
9. Exit code: `0`
10. Auth state saved

**Success Criteria:** Form submits successfully using the provided MFA code.

---

### Test Case 3: MFA Required - No Code (Manual Entry)
**Scenario:** MFA triggered but no code provided upfront. Browser stays open for 30s for manual entry.

**Steps:**
```bash
cd /home/ubuntu/.openclaw/workspace/skills/ms-forms-auto
node scripts/submit-with-mfa.js --date 2026-03-20
```

**Expected Behavior:**
1. Enters email and password
2. Detects MFA input field
3. **No code provided**, so:
   - Prints: "❌ MFA required but no code provided. Use --code flag."
   - Waits 30 seconds with browser open
   - If you manually type code during wait, it will proceed
   - If not, exits with code `2`

**Success Criteria:** Graceful handling, no crash, clear error message, exit code 2 if no manual intervention.

---

### Test Case 4: Already Authenticated (Cached Session)
**Scenario:** `config/storageState.json` contains a valid session cookie. No login needed.

**Steps:**
1. Ensure you have a valid `storageState.json` (from any previous successful login)
2. Run:
```bash
node scripts/submit-with-mfa.js --date 2026-03-20
```

**Expected Behavior:**
1. Browser opens, navigates to form URL
2. Already on form page (not redirected to login)
3. Skips credential entry entirely
4. Immediately fills and submits the form
5. Exit code: `0`

**Success Criteria:** Direct navigation to form, no login steps, fast submission.

---

### Test Case 5: Invalid Credentials
**Scenario:** Wrong email or password in `config/credentials.json`.

**Steps:**
```bash
node scripts/submit-with-mfa.js --date 2026-03-20 --code 123456
```

**Expected Behavior:**
1. Enters email and password
2. Detects error message on page ("Incorrect", "Invalid", "error")
3. Throws exception, closes browser
4. Exit code: `1`
5. Error message: "Login failed: Invalid email/password or account locked"

**Success Criteria:** Clear error, proper exit code, no false success.

---

## Warm/Cache Handling
**Important:** The script saves the auth state after every successful submission. This means:
- Next run will use cached session → no MFA (Test Case 4)
- To force fresh login for testing, delete `config/storageState.json` before Test Cases 1-3.

```bash
rm /home/ubuntu/.openclaw/workspace/skills/ms-forms-auto/config/storageState.json
```

---

## Integration Testing with Daily Cron

After verifying manual tests pass, we should also verify the cron job works:

### Cron Job Reference
- **Pre-Fill:** 5:45 PM SGT (Mon-Fri) → isolated agent → `calendar-fetch.js`
- **Submit:** 6:00 PM SGT (Mon-Fri) → main session → asks for MFA code → runs `submit-with-mfa.js --code [CODE]`

The cron's behavior is unchanged. It will still ask Master for the 6-digit code at 6 PM. The improved script will:
- Use the code if MFA appears
- Ignore it if no MFA needed
- Still succeed in either case

**Test with cron simulation:**
```bash
# In main session (simulating 6 PM trigger):
cd /home/ubuntu/.openclaw/workspace/skills/ms-forms-auto
node scripts/submit-with-mfa.js --code 123456 --date 2026-03-20
```

---

## Additional Edge Cases to Consider

1. **Conditional MFA**: Sometimes MFA is required based on IP/device. If testing from a different network than usual, MFA may always be required.

2. **"Stay signed in?" Prompt**: The script automatically clicks "Yes" if detected. If Microsoft changes the button text, detection might fail but it won't break the flow.

3. **Form Layout Changes**: The script relies on input field ordering. If the form changes, field mapping could break. Verify after any form edit by running calendar-fetch.js first to see the data.

4. **Network Timeouts**: Playwright timeouts are set at 60s for goto, 5-8s for waits. Adjust if your connection is slow.

---

## Rollback Plan

If something goes wrong, revert to previous version:
```bash
cd /home/ubuntu/.openclaw/workspace/skills/ms-forms-auto
git checkout HEAD -- scripts/submit-with-mfa.js
```

The old version is still in git history (commit before March 21 changes).

---

## Success Checklist

- [ ] Test Case 1 passes (no MFA, no code)
- [ ] Test Case 2 passes (MFA with code)
- [ ] Test Case 4 passes (cached session)
- [ ] Test Case 5 passes (invalid credentials error)
- [ ] No regressions in other scripts (calendar-fetch, pre-fill cron)
- [ ] March 20 entry submitted successfully at least once
- [ ] March 21 pre-fill generated correctly (already working per MEMORY.md)

---

## Next Steps After Testing

1. Run through all test cases that apply to your scenario
2. Document any failures or unexpected behaviors
3. If all pass, the skill is fixed and ready for daily cron
4. Update MEMORY.md with testing results and final status

---

**Testing Date:** _____________
**Tester:** _____________
**Result:** [ ] PASS  [ ] FAIL  [ ] NEEDS REVISION
**Notes:**


path: scripts/calendar-fetch.js
content: #!/usr/bin/env node
/**
 * Fetch both calendars and extract training + content dev data.
 * 
 * USAGE: node calendar-fetch.js [--date 2026-03-18]
 * Defaults to today (SGT) if no date provided.
 * 
 * CALENDARS:
 *   1. Training Calendar (TMS) → Training Hours (source of truth)
 *   2. Outlook Calendar       → Content Dev Hours (blockout periods)
 * 
 * Handles: UTC times, TZID (SGT/IST/SE Asia), VALUE=DATE all-day events, line folding
 */

const https = require('https');
const fs = require('fs');
const path = require('path');

// Load calendar URLs from config (falls back to example)
function loadCalendarUrls() {
  const configDir = path.join(__dirname, '..', 'config');
  const configFile = path.join(configDir, 'calendars.json');
  const exampleFile = path.join(configDir, 'calendars.json.example');
  
  const source = fs.existsSync(configFile) ? configFile : exampleFile;
  const config = JSON.parse(fs.readFileSync(source, 'utf-8'));
  
  if (!config.training || config.training.includes('YOUR_')) {
    throw new Error('Missing config/calendars.json — run: cp config/calendars.json.example config/calendars.json and add your URLs');
  }
  
  return config;
}

const { training: TRAINING_CAL_URL, outlook: OUTLOOK_CAL_URL } = loadCalendarUrls();

const CONTENT_DEV_KEYWORDS = [
  'content dev', 'content development', 'course dev', 'course development',
  'material dev', 'material development', 'module dev', 'module development',
  'blockout', 'content creation', 'curriculum', 'courseware',
];

const EXCLUDE_KEYWORDS = [
  'kt session',        // Knowledge Transfer sessions — not form-relevant
];

// Master's learning topic pool (randomized daily)
const LEARNING_TOPIC_POOL = [
  'Web3 & Cryptocurrency',
  'NFT Development',
  'Automated Trading Strategies',
  'Agentic AI Workflows',
  'OpenClaw Automation',
  'Power BI Dashboards',
  'Data Storytelling',
  'Japanese Language',
  'Prompt Engineering',
  'DeFi Protocols',
  'Machine Learning Fundamentals',
  'Corporate Training Methodologies',
  'Python for Data Analysis',
  'Cloud Infrastructure',
  'M365 Power Platform',
];

// Timezone offsets from UTC in hours
const TZ_OFFSETS = {
  'singapore standard time': 8,
  'sgt': 8,
  'asia/singapore': 8,
  'india standard time': 5.5,
  'ist': 5.5,
  'asia/kolkata': 5.5,
  'se asia standard time': 7,
  'asia/bangkok': 7,
};

function fetchCalendar(url) {
  return new Promise((resolve, reject) => {
    https.get(url, { timeout: 15000 }, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => resolve(data));
    }).on('error', reject);
  });
}

function parseIcalDate(raw) {
  // raw format: "DTSTART:20260318T110000Z" or "DTSTART;TZID=Singapore Standard Time:20260318T170000"
  // or "DTSTART;VALUE=DATE:20251129"
  
  // Extract the timezone ID if present
  const tzidMatch = raw.match(/TZID=([^:]+):/i);
  const tzId = tzidMatch ? tzidMatch[1].trim().toLowerCase() : null;
  
  // Extract the date/time value
  const valueMatch = raw.match(/:(\d{8}T?\d{0,6}Z?)\s*$/);
  if (!valueMatch) return null;
  
  const dtStr = valueMatch[1];
  
  // VALUE=DATE format: 20251129 (all-day event, midnight local)
  if (dtStr.length === 8) {
    const y = +dtStr.substring(0, 4);
    const m = +dtStr.substring(4, 6);
    const d = +dtStr.substring(6, 8);
    const offset = tzId ? (TZ_OFFSETS[tzId] || 8) : 8; // default SGT
    return new Date(Date.UTC(y, m - 1, d, -offset, 0, 0));
  }
  
  // UTC format: 20260318T110000Z
  if (dtStr.endsWith('Z')) {
    const y = +dtStr.substring(0, 4);
    const m = +dtStr.substring(4, 6);
    const d = +dtStr.substring(6, 8);
    const H = +dtStr.substring(9, 11);
    const M = +dtStr.substring(11, 13);
    const S = +dtStr.substring(13, 15);
    return new Date(Date.UTC(y, m - 1, d, H, M, S));
  }
  
  // Local time: 20260318T170000 — convert from the specified timezone
  const y = +dtStr.substring(0, 4);
  const m = +dtStr.substring(4, 6);
  const d = +dtStr.substring(6, 8);
  const H = +dtStr.substring(9, 11);
  const M = +dtStr.substring(11, 13);
  const S = +dtStr.substring(13, 15);
  const offset = tzId ? (TZ_OFFSETS[tzId] || 8) : 8; // default SGT
  return new Date(Date.UTC(y, m - 1, d, H - offset, M, S));
}

function getField(text, name) {
  const regex = new RegExp(`^${name}(?:;[^:]*)?:(.+)$`, 'm');
  const match = text.match(regex);
  return match ? match[1].trim() : null;
}

function getFieldRaw(text, name) {
  // Return the full line including TZID for date parsing
  const regex = new RegExp(`^${name}(?:;[^:]*)?:.+$`, 'm');
  return text.match(regex)?.[0] || null;
}

function getEventsForDate(icalText, targetDate) {
  // Unfold iCal line folding (RFC 5545)
  const unfolded = icalText.replace(/\r?\n[ \t]/g, '');
  
  const [year, month, day] = targetDate.split('-').map(Number);
  const dayStartSGT = new Date(Date.UTC(year, month - 1, day, -8, 0, 0));
  const dayEndSGT = new Date(Date.UTC(year, month - 1, day + 1, -8, 0, 0));
  
  const events = [];
  const eventBlocks = unfolded.split('BEGIN:VEVENT');
  
  for (const block of eventBlocks.slice(1)) {
    const endIdx = block.indexOf('END:VEVENT');
    const eventText = block.substring(0, endIdx < 0 ? block.length : endIdx);
    
    const summary = getField(eventText, 'SUMMARY');
    if (summary?.toLowerCase().startsWith('canceled')) continue;
    
    const dtstartRaw = getFieldRaw(eventText, 'DTSTART');
    const dtendRaw = getFieldRaw(eventText, 'DTEND');
    
    if (!dtstartRaw) continue;
    
    const startDate = parseIcalDate(dtstartRaw);
    const endDate = dtendRaw ? parseIcalDate(dtendRaw) : new Date(startDate.getTime() + 3600000);
    
    if (!startDate) continue;
    
    // Check if event overlaps with target day (in SGT)
    if (startDate < dayEndSGT && endDate > dayStartSGT) {
      const actualStart = startDate < dayStartSGT ? dayStartSGT : startDate;
      const actualEnd = endDate > dayEndSGT ? dayEndSGT : endDate;
      const durationHours = Math.round((actualEnd - actualStart) / 3600000 * 100) / 100;
      
      const toSGT = (d) => {
        const sgt = new Date(d.getTime() + 8 * 3600000);
        return `${String(sgt.getUTCHours()).padStart(2, '0')}:${String(sgt.getUTCMinutes()).padStart(2, '0')}`;
      };
      
      events.push({
        title: summary || 'Untitled',
        location: getField(eventText, 'LOCATION') || '',
        description: getField(eventText, 'DESCRIPTION') || '',
        startSGT: toSGT(startDate),
        endSGT: toSGT(endDate),
        durationHours,
      });
    }
  }
  
  return events;
}

function isContentDevEvent(event) {
  const text = `${event.title} ${event.description}`.toLowerCase();
  return CONTENT_DEV_KEYWORDS.some(kw => text.includes(kw));
}

function isExcludedEvent(event) {
  const text = `${event.title} ${event.description}`.toLowerCase();
  return EXCLUDE_KEYWORDS.some(kw => text.includes(kw));
}

function isOtherWorkEvent(event, trainingEvents) {
  // Exclude content dev events
  if (isContentDevEvent(event)) return false;
  // Exclude KT sessions and other filtered events
  if (isExcludedEvent(event)) return false;
  // Exclude events that overlap with training calendar (Outlook duplicates)
  for (const te of trainingEvents) {
    if (event.startSGT === te.startSGT && event.endSGT === te.endSGT) return false;
    // Also check if times overlap significantly (>50% overlap)
    if (event.startSGT < te.endSGT && event.endSGT > te.startSGT) {
      const overlapStart = event.startSGT > te.startSGT ? event.startSGT : te.startSGT;
      const overlapEnd = event.endSGT < te.endSGT ? event.endSGT : te.endSGT;
      const overlapMins = (parseTime(overlapEnd) - parseTime(overlapStart));
      const eventMins = (parseTime(event.endSGT) - parseTime(event.startSGT));
      if (overlapMins / eventMins > 0.5) return false;
    }
  }
  return true;
}

function parseTime(hhmm) {
  const [h, m] = hhmm.split(':').map(Number);
  return h * 60 + m;
}

function isKtSessionEvent(event) {
  const text = `${event.title} ${event.description}`.toLowerCase();
  return text.includes('kt session');
}

function getRandomLearningTopics(count) {
  const shuffled = [...LEARNING_TOPIC_POOL].sort(() => Math.random() - 0.5);
  return shuffled.slice(0, count);
}

function buildLearningTopic(outlookEvents) {
  const ktEvents = outlookEvents.filter(isKtSessionEvent);
  const topicCount = ktEvents.length > 0 ? 2 : 3;
  const randomTopics = getRandomLearningTopics(topicCount);
  
  if (ktEvents.length > 0) {
    const ktTitles = ktEvents.map(e => e.title);
    return [...randomTopics, ...ktTitles].join('; ');
  }
  
  return randomTopics.join(', ');
}

function extractContentDevTopic(event) {
  const desc = event.description;
  if (!desc || desc.length < 3) return event.title;
  
  let topic = desc
    .replace(/Microsoft Teams Need help\?.*/s, '')
    .replace(/Join the meeting now.*/s, '')
    .replace(/Meeting ID.*/s, '')
    .replace(/Passcode.*/s, '')
    .replace(/For organizers.*/s, '')
    .replace(/Meeting options.*/s, '')
    .replace(/________________________________.*/g, '')
    .replace(/\\n/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  
  return topic.length > 3 ? topic.substring(0, 200) : event.title;
}

function formatDateForForm(dateStr) {
  // Convert YYYY-MM-DD to M/d/yyyy for MS Forms
  const [year, month, day] = dateStr.split('-').map(Number);
  return `${month}/${day}/${year}`;
}

async function main() {
  const args = process.argv.slice(2);
  
  const nowSGT = new Date(Date.now() + 8 * 3600000);
  let targetDate = `${nowSGT.getUTCFullYear()}-${String(nowSGT.getUTCMonth() + 1).padStart(2, '0')}-${String(nowSGT.getUTCDate()).padStart(2, '0')}`;
  
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--date') targetDate = args[++i];
  }
  
  const errors = [];
  
  // Fetch training calendar (with fallback)
  let trainingEvents = [];
  try {
    const trainingIcal = await fetchCalendar(TRAINING_CAL_URL);
    trainingEvents = getEventsForDate(trainingIcal, targetDate);
  } catch (err) {
    errors.push(`Training calendar: ${err.message}`);
  }
  
  // Fetch Outlook calendar (with fallback)
  let outlookEvents = [];
  try {
    const outlookIcal = await fetchCalendar(OUTLOOK_CAL_URL);
    outlookEvents = getEventsForDate(outlookIcal, targetDate);
  } catch (err) {
    errors.push(`Outlook calendar: ${err.message}`);
  }
  
  const trainingHours = trainingEvents.reduce((sum, e) => sum + e.durationHours, 0);
  
  // Content dev = blockout events from Outlook
  const contentDevEvents = outlookEvents.filter(isContentDevEvent);
  const contentDevHours = contentDevEvents.reduce((sum, e) => sum + e.durationHours, 0);
  
  let contentDevTopic = 'NA';
  if (contentDevEvents.length > 0) {
    contentDevTopic = contentDevEvents.map(e => extractContentDevTopic(e)).join('; ');
  }
  
  // Other items = Outlook events that aren't content dev, aren't excluded, and aren't training duplicates
  const otherEvents = outlookEvents.filter(e => isOtherWorkEvent(e, trainingEvents));
  const otherParts = ['Preparation, testing, and rehearsal for my next class'];
  if (otherEvents.length > 0) {
    otherParts.push(...otherEvents.map(e => `${e.title} (${e.startSGT}-${e.endSGT})`));
  }
  const otherItems = otherParts.join('; ');
  
  // Learning hours = remaining time to make 8 hours total
  const learningHours = Math.max(0, Math.round((8 - trainingHours - contentDevHours) * 100) / 100);
  
  // Learning topic = 2-3 random topics + any KT Sessions
  const learningTopic = buildLearningTopic(outlookEvents);
  
  const result = {
    status: 'draft',
    date: formatDateForForm(targetDate),
    trainingHours: Math.round(trainingHours * 100) / 100,
    trainingEvents: trainingEvents.map(e => ({ title: e.title, time: `${e.startSGT}-${e.endSGT}`, hours: e.durationHours })),
    contentDevHours: Math.round(contentDevHours * 100) / 100,
    contentDevTopic,
    contentDevEvents: contentDevEvents.map(e => ({ title: e.title, time: `${e.startSGT}-${e.endSGT}`, hours: e.durationHours, topic: extractContentDevTopic(e) })),
    learningHours,
    learningTopic,
    otherItems,
    otherEvents: otherEvents.map(e => ({ title: e.title, time: `${e.startSGT}-${e.endSGT}`, hours: e.durationHours })),
    teamHours: '',
    teamDesc: '',
    _errors: errors.length > 0 ? errors : null,
  };
  
  console.log(JSON.stringify(result, null, 2));
}

module.exports = { fetchCalendar, getEventsForDate, isContentDevEvent, isExcludedEvent, isOtherWorkEvent, extractContentDevTopic, parseIcalDate, buildLearningTopic };

if (require.main === module) {
  main();
}


path: scripts/check-auth.js
content: const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');

const ROOT_DIR = path.join(__dirname, '..');
const AUTH_STATE = path.join(ROOT_DIR, 'config', 'storageState.json');
const FORM_URL = 'https://forms.cloud.microsoft/r/LsxLaEv13i';

(async () => {
  if (!fs.existsSync(AUTH_STATE)) {
    console.log('No storageState.json found at', AUTH_STATE);
    process.exit(1);
  }

  console.log('🔍 Checking if stored auth state is valid...');
  
  const browser = await chromium.launch({
    headless: false,
    args: ['--no-sandbox']
  });
  
  const context = await browser.newContext({ storageState: AUTH_STATE });
  const page = await context.newPage();
  
  await page.goto(FORM_URL, { waitUntil: 'networkidle', timeout: 30000 });
  await page.waitForTimeout(4000);
  
  const url = page.url();
  console.log('📍 Current URL:', url);
  
  const isLoginPage = url.includes('login.microsoftonline.com') || 
                      url.includes('login.live.com') ||
                      url.includes('account.activedirectory.windowsazure.com');
  
  const submitBtn = await page.$('button[data-automation-id="submitButton"]');
  const emailInput = await page.$('input[type="email"], input[name="loginfmt"]');
  
  console.log('✅ On login domain?', isLoginPage);
  console.log('📧 Email input present?', !!emailInput);
  console.log('🟢 Submit button present?', !!submitBtn);
  
  if (!isLoginPage && submitBtn && !emailInput) {
    console.log('\n🎉 SUCCESS: Cached session is VALID! Ready to submit form.');
  } else {
    console.log('\n⚠️  Cached session is NOT valid. Need fresh login.');
  }
  
  await browser.close();
  process.exit(0);
})();


path: scripts/mfa-login.js
content: #!/usr/bin/env node
/**
 * MFA Login Script for Microsoft 365
 * 
 * USAGE: xvfb-run --auto-servernum node mfa-login.js
 * 
 * This script navigates to the form, enters credentials, and pauses
 * at the MFA prompt. It saves the URL/state so the main process can
 * inject the code.
 * 
 * To complete: node mfa-login.js --code XXXXXX
 */

const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');

const ROOT_DIR = path.resolve(path.join(__dirname, '..'));
const CONFIG_DIR = path.join(ROOT_DIR, 'config');
const CREDS_FILE = path.join(CONFIG_DIR, 'credentials.json');
const AUTH_STATE = path.join(CONFIG_DIR, 'storageState.json');
const FORM_URL = 'https://forms.cloud.microsoft/r/LsxLaEv13i';

async function mfaLogin(mfaCode) {
  const creds = JSON.parse(fs.readFileSync(CREDS_FILE, 'utf8'));
  
  const browser = await chromium.launch({ 
    headless: false,  // Need headed for MFA
    args: ['--no-sandbox'] 
  });
  const page = await browser.newPage();
  
  try {
    await page.goto(FORM_URL, { waitUntil: 'networkidle', timeout: 60000 });
    await page.waitForTimeout(8000);
    
    // Enter email
    await page.fill('input[type="email"]', creds.email);
    const nextBtn = await page.$('input[type="submit"][value="Next"]');
    if (nextBtn) await nextBtn.click();
    await page.waitForTimeout(4000);
    
    // Enter password
    const pwdField = await page.$('input[type="password"]');
    if (pwdField) {
      await pwdField.fill(creds.password);
      const signInBtn = await page.$('input[type="submit"][value="Sign in"]');
      if (signInBtn) await signInBtn.click();
    }
    await page.waitForTimeout(4000);
    
    // MFA page
    const codeInput = await page.$('input[type="tel"]');
    if (codeInput) {
      if (mfaCode) {
        // Code provided - enter it
        await codeInput.fill(mfaCode);
        const submitBtn = await page.$('input[type="submit"]');
        if (submitBtn) await submitBtn.click();
        await page.waitForTimeout(4000);
        
        // Stay signed in?
        const yesBtn = await page.$('input[type="submit"][value="Yes"]');
        if (yesBtn) await yesBtn.click();
        await page.waitForTimeout(8000);
        
        // Save auth state
        const context = page.context();
        await context.storageState({ path: AUTH_STATE });
        console.log('✅ MFA completed! Auth state saved.');
        console.log('URL:', page.url().substring(0, 100));
      } else {
        // No code provided - wait for user to provide it
        const url = page.url();
        console.log('MFA_PROMPT');
        console.log('Waiting for code at:', url.substring(0, 80));
        console.log('Run: node mfa-login.js --code XXXXXX');
        
        // Keep browser open for 120 seconds
        await page.waitForTimeout(120000);
      }
    } else {
      console.log('No MFA prompt found. URL:', page.url().substring(0, 80));
      
      // Maybe already logged in?
      if (!page.url().includes('login.microsoftonline.com')) {
        const context = page.context();
        await context.storageState({ path: AUTH_STATE });
        console.log('✅ Already logged in! Auth state saved.');
      }
    }
    
  } catch (err) {
    console.error('Error:', err.message);
  }
  
  await browser.close();
}

// Parse args
const args = process.argv.slice(2);
let code = null;
for (let i = 0; i < args.length; i++) {
  if (args[i] === '--code') code = args[++i];
}

mfaLogin(code);


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)