Files
Dhairyashil ShindeandGitHub 2a84d46fe9 feat(companion): add Biome for linting and formatting (#26214)
* feat(companion): add ESLint with Prettier integration

- Add ESLint with eslint-config-expo/flat for SDK 55
- Integrate Prettier via eslint-plugin-prettier
- Configure environment-specific settings for Node.js config files and browser extension
- Add lint script to package.json
- Fix Prettier formatting issues in AvailabilityTab.tsx and types/index.ts

* fix(companion): use bun lockfile instead of npm

- Remove package-lock.json that was accidentally committed
- Update bun.lock with ESLint dependencies

* fix(companion): fix all ESLint errors (16 total)

- Fix unescaped entities in JSX (react/no-unescaped-entities):
  - app/(tabs)/(event-types)/index.tsx: escape quotes in delete confirmation
  - components/LoginScreen.tsx: escape apostrophe in "Don't have an account"
  - components/NetworkStatusBanner.tsx: escape apostrophes in offline message
  - components/booking-modals/BookingModals.tsx: escape apostrophe in rejection message
  - components/event-type-detail/tabs/AdvancedTab.tsx: escape quotes and apostrophes
  - components/screens/AvailabilityListScreen.tsx: escape quotes in delete confirmation

- Fix react-hooks/rules-of-hooks in RescheduleScreen.tsx:
  - Move useMemo calls before conditional early return to ensure hooks
    are called in the same order on every render

* fix(companion): fix all ESLint warnings (68 total)

- Fix import/no-named-as-default warnings by using named imports
- Fix @typescript-eslint/no-unused-vars warnings by removing unused imports/variables
- Fix react-hooks/exhaustive-deps warnings with eslint-disable comments
- Convert unused error variables in catch blocks to bare catch
- Remove unused imports (useRouter, Alert, useSafeAreaInsets, etc.)
- All ESLint checks now pass with 0 errors and 0 warnings

* feat(companion): add pre-commit lint check with --max-warnings=0

- Add companion lint check to root lint-staged.config.mjs
- Runs 'bun run lint -- --fix --max-warnings=0' for companion files
- Follows same pattern as apps/packages lint check
- Supports SKIP_WARNINGS=1 env var to bypass warning check if needed
- Commit will fail if any ESLint errors or warnings are present

* fix(companion): restore state variables and imports removed during ESLint fixes

- Restore locationAddress, locationLink, locationPhone state variables in event-type-detail.tsx (still used in fetchEventTypeData)
- Restore Alert import in BookingListScreen.tsx (still used for report booking)
- Add eslint-disable comments for the restored state variables since they're only used by setters

* test

* test

* fix eslint pre commit check

* fix: In React Native, HTML entities like ' are rendered literally as text, not decoded like in web browsers

* refactor(companion): remove ESLint and Prettier config in preparation for Biome

* feat(companion): add Biome for linting and formatting

- Install @biomejs/biome with exact version pinning (2.3.10)
- Add biome.json with formatting settings matching previous Prettier config
- Add lint, format, check, and check:ci scripts to package.json
- Update lint-staged to use Biome instead of Prettier
- Remove Prettier and prettier-plugin-tailwindcss dependencies
- Delete .prettierrc.js configuration file

* style(companion): apply Biome formatting and fix lint-staged config

- Update lint-staged to use 'biome format' instead of 'biome check'
  (pre-commit should only format, not lint)
- Apply Biome formatting to all companion files

* feat(companion): configure Biome for strict linting with pre-commit enforcement

- Apply Biome unsafe fixes (unused imports, optional chaining, etc.)
- Configure biome.json rules to disable overly strict rules for existing code:
  - noExplicitAny, noArrayIndexKey, useIterableCallbackReturn (suspicious)
  - useExhaustiveDependencies, noUnusedFunctionParameters (correctness)
  - noStaticOnlyClass (complexity)
  - noNonNullAssertion (style)
  - noStaticElementInteractions (a11y)
- Update lint-staged to use 'biome check --write --error-on-warnings'
  (pre-commit now runs both formatting AND linting, fails on warnings)

* fix(companion): enable noNonNullAssertion rule and fix all violations

- Enable noNonNullAssertion rule in biome.json (set to 'error')
- Fix 7 violations across the codebase:
  - hooks/useBookings.ts: Add runtime check before API call
  - hooks/useEventTypes.ts: Add runtime check before API call
  - hooks/useSchedules.ts: Add runtime check before API call
  - extension/entrypoints/content.ts: Add null checks for cache and getAttribute

* fix(companion): enable noStaticOnlyClass and noStaticElementInteractions rules

- Enable noStaticOnlyClass rule in biome.json
- Enable noStaticElementInteractions rule in biome.json
- Convert CalComAPIService from static class to object literal pattern
- Convert WebAuthService from static class to object literal pattern
- Add role="tooltip" to Tooltip.web.tsx for accessibility

* fix(companion): resolve TypeScript type errors in useEffect dependencies and remove unused state property

* fix(companion): enable noUnusedFunctionParameters rule and fix 9 violations

* fix(companion): enable noUnusedVariables rule and fix 4 violations

* fix(companion): enable useIterableCallbackReturn rule and fix 11 violations

* fix(companion): enable useExhaustiveDependencies rule and fix 7 violations

* fix(companion): enable noArrayIndexKey rule and fix 10 violations

* fix(companion): enable noExplicitAny rule and fix all violations

- Enable noExplicitAny rule in biome.json
- Replace any types with proper specific types across all files:
  - calcom.ts: Add proper type definitions for API responses and inputs
  - event-types.types.ts: Add BookingLimitsCount, BookingLimitsDuration, ConfirmationPolicy types
  - buildPartialUpdatePayload.ts: Fix type mismatches for booking limits functions
  - booking-actions.ts: Add proper types for booking action handlers
  - BookingDetailScreen.tsx, MarkNoShowScreen.tsx: Add types for attendee data
  - AvailabilityListItem.ios.tsx, BookingListItem.ios.tsx: Import SFSymbols7_0 type
  - extension/content.ts: Fix slot types and __cleanup invocation issues
- Remove unused imports and interfaces

* address cubics comments

* add useHookAtTopLevel rule
2025-12-27 08:19:53 -03:00

1392 lines
44 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <reference types="chrome" />
// Google Calendar integration: inject a no-show toggle next to attendees in event popups.
const bookingStatusCache = new Map<string, { data: Map<string, boolean>; timestamp: number }>();
const CACHE_TTL = 30000; // 30 seconds cache
let authStatusCache: { isAuthenticated: boolean; timestamp: number } | null = null;
const AUTH_CACHE_TTL = 5000; // 5 seconds
const NO_SHOW_BUTTON_SELECTOR = "[data-cal-noshow-btn]";
async function getIsAuthenticatedCached(): Promise<boolean> {
const now = Date.now();
if (authStatusCache && now - authStatusCache.timestamp < AUTH_CACHE_TTL) {
return authStatusCache.isAuthenticated;
}
const authResponse = await new Promise<{ isAuthenticated: boolean }>((resolve) => {
chrome.runtime.sendMessage({ action: "check-auth-status" }, (response) => {
if (chrome.runtime.lastError) {
resolve({ isAuthenticated: false });
} else {
resolve(response || { isAuthenticated: false });
}
});
});
authStatusCache = { isAuthenticated: authResponse.isAuthenticated, timestamp: now };
return authResponse.isAuthenticated;
}
// Styles for injected elements
const STYLES = `
.cal-noshow-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 10px;
height: 24px;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
border: 1px solid #dadce0;
background-color: #ffffff;
color: #3c4043;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
font-family: 'Google Sans', Roboto, Arial, sans-serif;
vertical-align: middle;
white-space: nowrap;
}
.cal-noshow-btn:hover {
background-color: #f8f9fa;
border-color: #bdc1c6;
color: #202124;
}
.cal-noshow-btn:active {
background-color: #f1f3f4;
border-color: #bdc1c6;
}
.cal-noshow-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: #f8f9fa;
}
.cal-noshow-btn.loading {
opacity: 0.7;
pointer-events: none;
}
.cal-noshow-btn.marked {
background-color: #f8f9fa;
border-color: #5f6368;
color: #202124;
}
.cal-noshow-btn.marked:hover {
background-color: #e8eaed;
border-color: #3c4043;
}
.cal-noshow-btn-icon {
width: 16px;
height: 16px;
display: block;
flex-shrink: 0;
}
.cal-noshow-btn-container {
display: inline-flex;
align-items: center;
margin-left: 12px;
vertical-align: middle;
}
[data-cal-noshow-marked="true"] .SDqFWd span,
[data-cal-noshow-marked="true"] .SDqFWd {
text-decoration: line-through;
color: #5f6368;
}
.cal-noshow-spinner {
width: 10px;
height: 10px;
border: 2px solid #dadce0;
border-top-color: #5f6368;
border-radius: 50%;
animation: cal-spin 0.6s linear infinite;
}
@keyframes cal-spin {
to { transform: rotate(360deg); }
}
.cal-noshow-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2147483647;
}
.cal-noshow-dialog {
background-color: white;
border-radius: 8px;
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
font-family: 'Google Sans', Roboto, Arial, sans-serif;
}
.cal-noshow-dialog h3 {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 500;
color: #111827;
}
.cal-noshow-dialog p {
margin: 0 0 20px 0;
font-size: 14px;
color: #6b7280;
line-height: 1.5;
}
.cal-noshow-dialog-buttons {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.cal-noshow-dialog-btn {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
font-family: 'Google Sans', Roboto, Arial, sans-serif;
}
.cal-noshow-dialog-btn.cancel {
background-color: white;
border: 1px solid #d1d5db;
color: #374151;
}
.cal-noshow-dialog-btn.cancel:hover {
background-color: #f9fafb;
}
.cal-noshow-dialog-btn.confirm {
background-color: #202124;
border: 1px solid #202124;
color: white;
}
.cal-noshow-dialog-btn.confirm:hover {
background-color: #000000;
}
.cal-noshow-dialog-btn.undo {
background-color: #202124;
border: 1px solid #202124;
color: white;
}
.cal-noshow-dialog-btn.undo:hover {
background-color: #000000;
}
.cal-noshow-alert {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 16px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
z-index: 2147483647;
font-family: 'Google Sans', Roboto, Arial, sans-serif;
animation: cal-fade-in 0.3s ease;
min-width: 200px;
text-align: center;
}
.cal-noshow-alert.error {
background-color: #ffffff;
border: 1px solid #dadce0;
color: #202124;
}
.cal-noshow-alert.success {
background-color: #ffffff;
border: 1px solid #dadce0;
color: #202124;
}
.cal-noshow-alert.info {
background-color: #ffffff;
border: 1px solid #dadce0;
color: #202124;
}
@keyframes cal-fade-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
`;
function setAttendeeMarkedVisual(attendeeElement: Element | null, marked: boolean): void {
if (!attendeeElement) return;
if (marked) {
attendeeElement.setAttribute("data-cal-noshow-marked", "true");
} else {
attendeeElement.removeAttribute("data-cal-noshow-marked");
}
}
// Inject styles into the page
function injectStyles(): void {
if (document.getElementById("cal-noshow-styles")) return;
const styleSheet = document.createElement("style");
styleSheet.id = "cal-noshow-styles";
styleSheet.textContent = STYLES;
document.head.appendChild(styleSheet);
}
/**
* Extract booking UID from event description
* Looks for patterns like: https://cal.com/booking/{uid} or https://app.cal.com/booking/{uid}
*/
function extractBookingUid(text: string): string | null {
const regex = /https:\/\/(?:app\.)?cal\.com\/booking\/([a-zA-Z0-9]+)/;
const match = text.match(regex);
return match ? match[1] : null;
}
/**
* Check if an event is in the past
*/
function isEventInPast(eventElement: Element): boolean {
const now = new Date();
const dateTimeText = eventElement.textContent || "";
// Try to infer the end time from the popup text (best effort).
const datePatterns = [
// Full date format: "Saturday, 20 December 2024⋅11:00 – 11:30am"
/(\d{1,2})\s+(January|February|March|April|May|June|July|August|September|October|November|December)(?:\s+(\d{4}))?/i,
// Short date format: "Dec 20, 2024"
/(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),?\s+(\d{4})/i,
// Numeric format: "12/20/2024" or "20/12/2024"
/(\d{1,2})[/-](\d{1,2})[/-](\d{4})/,
];
const months: Record<string, number> = {
january: 0,
february: 1,
march: 2,
april: 3,
may: 4,
june: 5,
july: 6,
august: 7,
september: 8,
october: 9,
november: 10,
december: 11,
};
for (const pattern of datePatterns) {
const match = dateTimeText.match(pattern);
if (match) {
let day: number, month: number, year: number;
if (pattern === datePatterns[0]) {
// Format: "20 December 2024"
day = parseInt(match[1], 10);
month = months[match[2].toLowerCase()];
year = match[3] ? parseInt(match[3], 10) : now.getFullYear();
} else if (pattern === datePatterns[1]) {
// Format: "December 20, 2024"
month = months[match[1].toLowerCase()];
day = parseInt(match[2], 10);
year = parseInt(match[3], 10);
} else {
// Format: "12/20/2024" or "20/12/2024"
// Assume US format (MM/DD/YYYY) first, fallback to DD/MM/YYYY
const first = parseInt(match[1], 10);
const second = parseInt(match[2], 10);
year = parseInt(match[3], 10);
if (first > 12) {
// DD/MM/YYYY format
day = first;
month = second - 1;
} else {
// MM/DD/YYYY format
month = first - 1;
day = second;
}
}
// Extract end time if available to check if event has ended
const timeMatch = dateTimeText.match(/(\d{1,2}):(\d{2})\s*(?:AM|PM|am|pm)?/g);
if (timeMatch && timeMatch.length > 0) {
// Use the last time (end time)
const lastTime = timeMatch[timeMatch.length - 1];
const timeParts = lastTime.match(/(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)?/i);
if (timeParts) {
let hours = parseInt(timeParts[1], 10);
const minutes = parseInt(timeParts[2], 10);
const isPM = timeParts[3]?.toUpperCase() === "PM";
if (isPM && hours !== 12) {
hours += 12;
} else if (!isPM && hours === 12) {
hours = 0;
}
const eventDate = new Date(year, month, day, hours, minutes);
return eventDate < now;
}
}
// If no time found, check if the date itself is in the past
const eventDate = new Date(year, month, day, 23, 59, 59);
return eventDate < now;
}
}
// Fallback: data attributes (if present).
const eventStartAttr =
eventElement.getAttribute("data-start") || eventElement.getAttribute("data-event-start");
if (eventStartAttr) {
try {
const eventDate = new Date(eventStartAttr);
if (!Number.isNaN(eventDate.getTime())) {
return eventDate < now;
}
} catch {
// Invalid date, continue to next strategy
}
}
// Fallback: CSS/aria hints.
const hasPastIndicator =
eventElement.classList.contains("past") ||
eventElement.classList.contains("completed") ||
eventElement.getAttribute("aria-label")?.toLowerCase().includes("past") ||
eventElement.querySelector(".past, .completed") !== null;
if (hasPastIndicator) {
return true;
}
// Fallback: keyword hints.
const pastKeywords = ["ended", "was", "past meeting", "completed", "finished"];
const textLower = dateTimeText.toLowerCase();
if (pastKeywords.some((keyword) => textLower.includes(keyword))) {
// Additional check: make sure it's not a future event description
const futureKeywords = ["will", "upcoming", "scheduled for", "starts"];
if (!futureKeywords.some((keyword) => textLower.includes(keyword))) {
return true;
}
}
// Default: assume not past if we can't determine
return false;
}
/**
* Parse attendee information from the event popup
*/
interface AttendeeInfo {
name: string;
email: string;
isOrganizer: boolean;
element: Element;
}
function parseAttendees(eventPopup: Element): AttendeeInfo[] {
const attendees: AttendeeInfo[] = [];
const seenEmails = new Set<string>();
// Prefer Google Calendar attendee rows.
const attendeeItems = eventPopup.querySelectorAll('[role="treeitem"][data-email]');
attendeeItems.forEach((el) => {
const email = el.getAttribute("data-email");
if (!email || seenEmails.has(email.toLowerCase())) {
return;
}
seenEmails.add(email.toLowerCase());
// Check if this is an organizer using aria-label
const ariaLabel = el.getAttribute("aria-label") || "";
const isOrganizer =
ariaLabel.toLowerCase().includes("organiser") ||
ariaLabel.toLowerCase().includes("organizer") ||
el.textContent?.toLowerCase().includes("organiser") ||
el.textContent?.toLowerCase().includes("organizer");
// Extract name from the SDqFWd span (Google Calendar's name display)
let name = email.split("@")[0];
const nameSpan = el.querySelector(".SDqFWd span");
if (nameSpan?.textContent) {
const nameText = nameSpan.textContent.trim();
// Only use if it's not just the email
if (nameText !== email && nameText.includes("@") === false) {
name = nameText;
}
} else {
// Fallback: try to extract from aria-label
const labelMatch = ariaLabel.match(/^([^,]+)/);
if (labelMatch && labelMatch[1] !== email) {
name = labelMatch[1].trim();
}
}
attendees.push({
name: name || email.split("@")[0],
email: email,
isOrganizer,
element: el,
});
});
// Fallbacks (DOM variants, then text parsing).
const allText = eventPopup.innerHTML || eventPopup.textContent || "";
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
if (attendees.length === 0) {
const guestSelectors = [
"[data-email]",
"[data-guest-email]",
"[data-attendee-email]",
'[role="listitem"][data-email]',
"[data-email-id]",
'[jsname="xR2lFb"]',
'[jsname="xR2lFb"] [data-email]',
"[data-attendee]",
"[data-participant]",
];
for (const selector of guestSelectors) {
const guestElements = eventPopup.querySelectorAll(selector);
guestElements.forEach((el) => {
const email =
el.getAttribute("data-email") ||
el.getAttribute("data-guest-email") ||
el.getAttribute("data-attendee-email") ||
el.getAttribute("data-email-id");
if (email && !seenEmails.has(email.toLowerCase())) {
seenEmails.add(email.toLowerCase());
// Check if this is an organizer
const text = (el.textContent || "").toLowerCase();
const ariaLabel = el.getAttribute("aria-label") || "";
const isOrganizer =
text.includes("organiser") ||
text.includes("organizer") ||
ariaLabel.toLowerCase().includes("organiser") ||
ariaLabel.toLowerCase().includes("organizer") ||
el.getAttribute("data-organizer") === "true" ||
el.closest('[data-organizer="true"]') !== null;
// Extract name
let name = email.split("@")[0];
const nameElement = el.querySelector("[data-name], [data-display-name], .SDqFWd span");
if (nameElement) {
const nameText =
nameElement.textContent ||
nameElement.getAttribute("data-name") ||
nameElement.getAttribute("data-display-name");
if (nameText && nameText !== email && !nameText.includes("@")) {
name = nameText.trim();
}
} else {
// Try to extract name from text content
const textContent = el.textContent || "";
const nameMatch = textContent.match(
/^([^@\n]+?)(?:\s*[-–]\s*)?(?:organizer|organiser)?\s*$/i
);
if (nameMatch && nameMatch[1].trim() !== email) {
name = nameMatch[1].trim();
}
}
attendees.push({
name: name || email.split("@")[0],
email: email,
isOrganizer,
element: el,
});
}
});
}
}
// Fallback: parse from "Who:" section.
if (attendees.length === 0) {
const whoSection = allText.match(/Who:([\s\S]*?)(?:Where:|When:|$)/i);
if (whoSection) {
const whoText = whoSection[1];
const whoEmails: string[] = whoText.match(emailRegex) ?? [];
whoEmails.forEach((email) => {
const emailLower = email.toLowerCase();
if (seenEmails.has(emailLower)) return;
seenEmails.add(emailLower);
const isOrganizer =
whoText.toLowerCase().includes(`${emailLower}`) &&
(whoText.toLowerCase().includes("organizer") ||
whoText.toLowerCase().includes("organiser"));
// Try to find the name associated with this email
const nameMatch = whoText.match(
new RegExp(
`([^\\n]+?)\\s*[-–]?\\s*(?:Organizer|Organiser)?\\s*${email.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&"
)}`,
"i"
)
);
attendees.push({
name: nameMatch ? nameMatch[1].trim() : email.split("@")[0],
email: email,
isOrganizer,
element: eventPopup,
});
});
}
}
// Fallback: scan guest list containers.
if (attendees.length === 0) {
const guestListContainers = eventPopup.querySelectorAll(
'[data-guests], [aria-label*="guest" i], [aria-label*="attendee" i], [jsname="xR2lFb"]'
);
guestListContainers.forEach((container) => {
const containerText = container.textContent || "";
const emails: string[] = containerText.match(emailRegex) ?? [];
emails.forEach((email) => {
const emailLower = email.toLowerCase();
if (seenEmails.has(emailLower)) return;
seenEmails.add(emailLower);
const text = containerText.toLowerCase();
const isOrganizer =
text.includes("organiser") ||
text.includes("organizer") ||
container.querySelector('[aria-label*="organizer" i]') !== null;
// Find the specific element containing this email
const emailElement = Array.from(container.querySelectorAll("*")).find((el) =>
el.textContent?.includes(email)
);
const nameMatch = containerText.match(
new RegExp(
`([^\\n]+?)\\s*[-–]?\\s*(?:Organizer|Organiser)?\\s*${email.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&"
)}`,
"i"
)
);
attendees.push({
name: nameMatch ? nameMatch[1].trim() : email.split("@")[0],
email: email,
isOrganizer,
element: emailElement || container,
});
});
});
}
return attendees;
}
/**
* Show confirmation dialog
*/
function showConfirmDialog(
attendeeEmail: string,
isUndo: boolean,
onConfirm: () => void,
onCancel: () => void
): void {
const overlay = document.createElement("div");
overlay.className = "cal-noshow-dialog-overlay";
const dialog = document.createElement("div");
dialog.className = "cal-noshow-dialog";
const title = isUndo ? "Undo No Show?" : "Mark as No Show?";
const message = isUndo
? `Remove the no-show status from "${attendeeEmail}"?`
: `Mark "${attendeeEmail}" as no-show for this meeting?`;
const confirmClass = isUndo ? "undo" : "confirm";
const confirmText = "Confirm";
dialog.innerHTML = `
<h3>${title}</h3>
<p>${message}</p>
<div class="cal-noshow-dialog-buttons">
<button class="cal-noshow-dialog-btn cancel">Cancel</button>
<button class="cal-noshow-dialog-btn ${confirmClass}">${confirmText}</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
const cancelBtn = dialog.querySelector(".cancel");
const confirmBtn = dialog.querySelector(`.${confirmClass}`);
const cleanup = () => {
overlay.remove();
};
cancelBtn?.addEventListener("click", () => {
cleanup();
onCancel();
});
confirmBtn?.addEventListener("click", () => {
cleanup();
onConfirm();
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) {
cleanup();
onCancel();
}
});
}
/**
* Show alert message
*/
function showAlert(message: string, type: "error" | "success" | "info" = "info"): void {
// Remove existing alerts
for (const el of document.querySelectorAll(".cal-noshow-alert")) {
el.remove();
}
const alert = document.createElement("div");
alert.className = `cal-noshow-alert ${type}`;
alert.textContent = message;
document.body.appendChild(alert);
setTimeout(() => {
alert.remove();
}, 1200);
}
/**
* Open the companion sidebar for login
*/
function openSidebarForLogin(): void {
// Dispatch custom event that content script listens for
window.dispatchEvent(new CustomEvent("cal-companion-open-sidebar"));
}
/**
* Handle mark no-show button click
*/
async function handleMarkNoShow(
container: HTMLDivElement,
bookingUid: string,
attendeeEmail: string,
isCurrentlyMarked: boolean
): Promise<void> {
// Check authentication first
if (!(await getIsAuthenticatedCached())) {
// Not logged in - open sidebar for login (no alert, just open sidebar)
openSidebarForLogin();
return;
}
// If no booking UID, show error
if (!bookingUid) {
showAlert("Not a Cal.com booking", "info");
return;
}
// Show confirmation dialog
showConfirmDialog(
attendeeEmail,
isCurrentlyMarked,
async () => {
// Set loading state
const button = container.querySelector(".cal-noshow-btn") as HTMLButtonElement;
if (button) {
button.classList.add("loading");
button.disabled = true;
}
const originalButtonContent = button?.innerHTML || "";
if (button) {
button.innerHTML = '<div class="cal-noshow-spinner"></div>';
}
try {
const response = await new Promise<{ success: boolean; error?: string }>((resolve) => {
chrome.runtime.sendMessage(
{
action: "mark-no-show",
payload: {
bookingUid,
attendeeEmail,
absent: !isCurrentlyMarked,
},
},
(res) => {
if (chrome.runtime.lastError) {
// Handle network errors
const errorMsg =
chrome.runtime.lastError.message ||
"Network error. Please check your connection and try again.";
resolve({ success: false, error: errorMsg });
} else {
resolve(res || { success: false, error: "No response" });
}
}
);
});
if (response.success) {
// Invalidate cache so next time we fetch fresh data
if (bookingUid) {
bookingStatusCache.delete(bookingUid);
}
// Update button state - re-find button in case DOM changed
const updatedButton = container.querySelector(".cal-noshow-btn") as HTMLButtonElement;
const buttonToUpdate = updatedButton || button;
if (!isCurrentlyMarked) {
// Mark as no-show
if (buttonToUpdate) {
buttonToUpdate.classList.add("marked");
buttonToUpdate.innerHTML = `${createEyeIcon()}<span>Unmark no-show</span>`;
buttonToUpdate.setAttribute("aria-label", "Unmark no-show");
}
container.dataset.marked = "true";
setAttendeeMarkedVisual(
container.closest('[role="treeitem"][data-email]') ??
container.closest("[data-email]"),
true
);
showAlert("Marked as no-show", "success");
} else {
// Unmark no-show
if (buttonToUpdate) {
buttonToUpdate.classList.remove("marked");
buttonToUpdate.innerHTML = `${createNoShowIcon()}<span>Mark as no-show</span>`;
buttonToUpdate.setAttribute("aria-label", "Mark as no-show");
}
container.dataset.marked = "false";
setAttendeeMarkedVisual(
container.closest('[role="treeitem"][data-email]') ??
container.closest("[data-email]"),
false
);
showAlert("No-show status removed", "success");
}
} else {
// Handle specific error messages
let errorMessage = response.error || "Failed to mark no-show. Please try again.";
// Map error messages to user-friendly ones
if (response.error?.includes("401") || response.error?.includes("Session expired")) {
errorMessage = "Session expired. Please login again.";
} else if (response.error?.includes("403") || response.error?.includes("permission")) {
errorMessage = "You don't have permission to modify this booking.";
} else if (response.error?.includes("404") || response.error?.includes("not found")) {
errorMessage = "Booking not found in Cal.com.";
} else if (response.error?.includes("Network") || response.error?.includes("network")) {
errorMessage = "Network error. Please check your connection and try again.";
}
if (button) {
button.innerHTML = originalButtonContent;
}
showAlert(errorMessage, "error");
}
} catch (error) {
if (button) {
button.innerHTML = originalButtonContent;
}
const errorMessage =
error instanceof Error ? error.message : "Failed to mark no-show. Please try again.";
showAlert(errorMessage, "error");
} finally {
if (button) {
button.classList.remove("loading");
button.disabled = false;
}
}
},
() => {
// Cancel - do nothing
}
);
}
/**
* Create SVG icon for "Mark as no-show" (eye with slash)
*/
function createNoShowIcon(): string {
return `<svg class="cal-noshow-btn-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" fill="currentColor"/>
<line x1="1" y1="1" x2="23" y2="23" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>`;
}
/**
* Create SVG icon for "Unmark no-show" (regular eye)
*/
function createEyeIcon(): string {
return `<svg class="cal-noshow-btn-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" fill="currentColor"/>
</svg>`;
}
/**
* Create a "Mark No Show" button container for an attendee
* Returns a container div with icon + text button
*/
function createNoShowButton(
bookingUid: string,
attendee: AttendeeInfo,
isMarked: boolean = false
): HTMLDivElement {
const container = document.createElement("div");
container.className = "cal-noshow-btn-container";
container.dataset.attendeeEmail = attendee.email;
container.dataset.marked = isMarked ? "true" : "false";
container.dataset.calNoshowBtn = "true";
container.dataset.bookingUid = bookingUid || "";
// Create button with icon and text
const button = document.createElement("button");
button.className = `cal-noshow-btn ${isMarked ? "marked" : ""}`;
const iconHtml = isMarked ? createEyeIcon() : createNoShowIcon();
const text = isMarked ? "Unmark no-show" : "Mark as no-show";
button.innerHTML = `${iconHtml}<span>${text}</span>`;
button.dataset.attendeeEmail = attendee.email;
button.setAttribute("aria-label", text);
const handleClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
const currentlyMarked = container.dataset.marked === "true";
// Read the latest booking UID from the DOM at click-time (popup content can render in phases)
const latestBookingUid = container.dataset.bookingUid || bookingUid;
handleMarkNoShow(container, latestBookingUid, attendee.email, currentlyMarked);
};
button.addEventListener("click", handleClick);
container.appendChild(button);
return container;
}
/**
* Inject no-show buttons into an event popup
*/
async function injectNoShowButtons(eventPopup: Element): Promise<void> {
// Get the event description text
const popupText = eventPopup.textContent || "";
// Extract booking UID
const bookingUid = extractBookingUid(popupText);
// Check if this is a past event
const isPast = isEventInPast(eventPopup);
if (!isPast) {
// Not a past event, don't show buttons
return;
}
// Parse attendees
const attendees = parseAttendees(eventPopup);
// Filter out organizers and hosts (we only mark attendees, not the host/organizer)
// Also filter out the current user if they're the organizer
const nonOrganizerAttendees = attendees.filter((a) => {
// Exclude if marked as organizer
if (a.isOrganizer) {
return false;
}
// Additional check: exclude if the element or parent has organizer indicators
const element = a.element;
const hasOrganizerIndicator =
element.getAttribute("data-organizer") === "true" ||
element.closest('[data-organizer="true"]') !== null ||
element.querySelector('[aria-label*="organizer" i]') !== null ||
element.querySelector('[aria-label*="organiser" i]') !== null ||
(element.textContent || "").toLowerCase().includes("organizer") ||
(element.textContent || "").toLowerCase().includes("organiser");
return !hasOrganizerIndicator;
});
if (nonOrganizerAttendees.length === 0) {
return;
}
// Per requirements: Show button even for non-Cal.com events, but it will show error on click
// This allows users to see the feature is available
const existingButtonContainers = Array.from(
eventPopup.querySelectorAll(".cal-noshow-btn-container[data-attendee-email]")
) as HTMLDivElement[];
const existingButtonByEmail = new Map<string, HTMLDivElement>();
existingButtonContainers.forEach((el) => {
const email = el.getAttribute("data-attendee-email")?.toLowerCase();
if (email) existingButtonByEmail.set(email, el);
});
const attendeesToInject = nonOrganizerAttendees.filter(
(a) => !existingButtonByEmail.has(a.email.toLowerCase())
);
type BookingStatusAttendee = { email: string; noShow?: boolean; absent?: boolean };
type BookingStatusPayload = { attendees?: BookingStatusAttendee[] };
type BookingStatusMessageResponse =
| { success: true; data: BookingStatusPayload }
| { success: false; error?: string };
// Fetch booking status to compute initial mark/unmark state.
const attendeeNoShowStatus = new Map<string, boolean>();
if (bookingUid && (await getIsAuthenticatedCached())) {
// Check cache first
const cached = bookingStatusCache.get(bookingUid);
const now = Date.now();
if (cached && now - cached.timestamp < CACHE_TTL) {
// Use cached data
cached.data.forEach((value, key) => {
attendeeNoShowStatus.set(key, value);
});
} else {
const statusResponse = await new Promise<BookingStatusMessageResponse>((resolve) => {
chrome.runtime.sendMessage(
{ action: "get-booking-status", payload: { bookingUid } },
(response) => {
if (chrome.runtime.lastError) {
resolve({ success: false, error: chrome.runtime.lastError.message });
return;
}
if (response && typeof response === "object" && "success" in response) {
resolve(response as BookingStatusMessageResponse);
return;
}
resolve({ success: false });
}
);
});
if (statusResponse.success && statusResponse.data.attendees) {
statusResponse.data.attendees.forEach((att) => {
attendeeNoShowStatus.set(
att.email.toLowerCase(),
att.noShow === true || att.absent === true
);
});
bookingStatusCache.set(bookingUid, {
data: new Map(attendeeNoShowStatus),
timestamp: now,
});
}
}
}
// Sync existing buttons (e.g. when bookingUid appears after initial injection)
if (bookingUid && existingButtonByEmail.size > 0) {
nonOrganizerAttendees.forEach((attendee) => {
const emailLower = attendee.email.toLowerCase();
const existingContainer = existingButtonByEmail.get(emailLower);
if (!existingContainer) return;
// Ensure click-time booking UID is correct even if we injected before the link rendered
if (!existingContainer.dataset.bookingUid) existingContainer.dataset.bookingUid = bookingUid;
if (attendeeNoShowStatus.size === 0) return;
const shouldBeMarked = attendeeNoShowStatus.get(emailLower) || false;
const currentlyMarked = existingContainer.dataset.marked === "true";
if (shouldBeMarked === currentlyMarked) return;
const btn = existingContainer.querySelector(".cal-noshow-btn") as HTMLButtonElement | null;
if (btn) {
if (shouldBeMarked) {
btn.classList.add("marked");
btn.innerHTML = `${createEyeIcon()}<span>Unmark no-show</span>`;
btn.setAttribute("aria-label", "Unmark no-show");
} else {
btn.classList.remove("marked");
btn.innerHTML = `${createNoShowIcon()}<span>Mark as no-show</span>`;
btn.setAttribute("aria-label", "Mark as no-show");
}
}
setAttendeeMarkedVisual(
existingContainer.closest('[role="treeitem"][data-email]') ??
existingContainer.closest("[data-email]") ??
attendee.element,
shouldBeMarked
);
existingContainer.dataset.marked = shouldBeMarked ? "true" : "false";
});
}
if (attendeesToInject.length === 0) {
// Nothing new to inject (but we may have synced existing buttons above)
return;
}
attendeesToInject.forEach((attendee) => {
// Check if this attendee is already marked as no-show
const isMarked = attendeeNoShowStatus.get(attendee.email.toLowerCase()) || false;
// Create and inject button container
const buttonContainer = createNoShowButton(bookingUid || "", attendee, isMarked);
// Button will always show, even without booking UID
// The click handler will check auth and booking UID when clicked
// Strategy: Place button IN FRONT OF attendee name
// Insert before the name span (.SDqFWd span)
let inserted = false;
if (attendee.element && attendee.element !== eventPopup) {
// Find the name container and name span
const nameContainer = attendee.element.querySelector(".toUqff.DbpAnb, .CVKLNd");
const nameSpan = nameContainer
? nameContainer.querySelector(".SDqFWd span, .SDqFWd")
: attendee.element.querySelector(".SDqFWd span, .SDqFWd");
if (nameSpan?.parentElement) {
// Insert button AFTER the name span (on the right side)
// Check if button already exists at this location
const parent = nameSpan.parentElement;
const existingBtn = parent.querySelector(`[data-attendee-email="${attendee.email}"]`);
if (existingBtn) {
return; // Skip if already exists
}
// Insert after the name span
parent.insertBefore(buttonContainer, nameSpan.nextSibling);
inserted = true;
} else if (nameContainer) {
// Fallback: Insert at the beginning of the name container
const existingBtn = nameContainer.querySelector(
`[data-attendee-email="${attendee.email}"]`
);
if (existingBtn) {
return; // Skip if already exists
}
const firstChild = nameContainer.firstChild;
if (firstChild) {
nameContainer.insertBefore(buttonContainer, firstChild);
} else {
nameContainer.appendChild(buttonContainer);
}
inserted = true;
} else {
// Fallback: Find the content div and insert at the beginning
const contentDiv = attendee.element.querySelector(".toUqff, .CVKLNd, .UfeRlc");
if (contentDiv) {
const existingBtn = contentDiv.querySelector(`[data-attendee-email="${attendee.email}"]`);
if (existingBtn) {
return; // Skip if already exists
}
const firstChild = contentDiv.firstChild;
if (firstChild) {
contentDiv.insertBefore(buttonContainer, firstChild);
} else {
contentDiv.appendChild(buttonContainer);
}
inserted = true;
} else {
// Last resort: prepend to the attendee element itself
const existingBtn = attendee.element.querySelector(
`[data-attendee-email="${attendee.email}"]`
);
if (existingBtn) {
return; // Skip if already exists
}
const firstChild = attendee.element.firstChild;
if (firstChild) {
attendee.element.insertBefore(buttonContainer, firstChild);
} else {
attendee.element.appendChild(buttonContainer);
}
inserted = true;
}
}
}
// Fallback: If we couldn't insert using the structure, try finding by email
if (!inserted) {
// Look for the element with this email
const emailElement = eventPopup.querySelector(`[data-email="${attendee.email}"]`);
if (emailElement && emailElement !== eventPopup) {
const nameSpan = emailElement.querySelector(".SDqFWd span, .SDqFWd");
if (nameSpan?.parentElement) {
nameSpan.parentElement.insertBefore(buttonContainer, nameSpan.nextSibling);
inserted = true;
} else if (emailElement.parentElement) {
emailElement.parentElement.insertBefore(buttonContainer, emailElement.nextSibling);
inserted = true;
}
}
}
setAttendeeMarkedVisual(attendee.element, isMarked);
});
}
/**
* Observe DOM for event popups and inject buttons
*/
function observeEventPopups(): void {
// Track button injection attempts to avoid infinite loops
const injectionAttempts = new WeakMap<Element, number>();
const MAX_INJECTION_ATTEMPTS = 10;
// Throttle sync attempts per popup (for phased rendering / login while open)
const lastSyncAttempt = new WeakMap<Element, number>();
const SYNC_ATTEMPT_TTL = 2000; // 2 seconds
// Function to check and inject buttons
const checkAndInject = (element: Element, force: boolean = false) => {
const attempts = injectionAttempts.get(element) || 0;
// Prevent infinite injection loops
if (!force && attempts >= MAX_INJECTION_ATTEMPTS) {
return;
}
const text = element.textContent || "";
// Check if this looks like a Cal.com event
const isCalComEvent =
text.includes("cal.com") ||
text.includes("Cal.com") ||
text.includes("booking") ||
extractBookingUid(text) !== null;
if (isCalComEvent) {
// Check if buttons already exist
const existingButtons = element.querySelectorAll(NO_SHOW_BUTTON_SELECTOR);
const hasValidButtons = Array.from(existingButtons).some((btn) => {
const style = window.getComputedStyle(btn);
return btn.isConnected && style.display !== "none" && style.visibility !== "hidden";
});
// Only inject if buttons don't exist or were removed
if (!hasValidButtons || force) {
// Delay to let the popup fully render
setTimeout(() => {
void injectNoShowButtons(element);
injectionAttempts.set(element, attempts + 1);
}, 200);
return;
}
// Buttons exist: still attempt a throttled "sync pass" so state can update once bookingUid/auth becomes available.
const now = Date.now();
const last = lastSyncAttempt.get(element) || 0;
if (now - last < SYNC_ATTEMPT_TTL) return;
if (extractBookingUid(text)) {
lastSyncAttempt.set(element, now);
setTimeout(() => {
void injectNoShowButtons(element);
}, 200);
}
}
};
// MutationObserver to watch for event popup appearance and changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Handle removed nodes - check if our buttons were removed
if (mutation.type === "childList" && mutation.removedNodes.length > 0) {
mutation.removedNodes.forEach((removedNode) => {
if (removedNode instanceof Element) {
// Check if one of our buttons was removed
const removedButton =
removedNode.querySelector?.(NO_SHOW_BUTTON_SELECTOR) ||
(removedNode.hasAttribute?.("data-cal-noshow-btn") ? removedNode : null);
if (removedButton) {
// Find the popup that contains this button's attendee
const popup =
mutation.target instanceof Element
? mutation.target.closest('[role="dialog"], [aria-modal="true"], [data-eventid]')
: null;
if (popup) {
// Re-inject buttons if they were removed
setTimeout(() => checkAndInject(popup, true), 100);
}
}
}
});
}
// Handle added nodes
mutation.addedNodes.forEach((node) => {
if (node instanceof Element) {
// Look for event detail popups
const eventPopups = node.querySelectorAll(
'[data-eventid], [data-eventchip], [role="dialog"], [aria-modal="true"]'
);
eventPopups.forEach((popup) => {
checkAndInject(popup);
});
// Also check if the added node itself is a popup
if (
node.matches('[data-eventid], [data-eventchip], [role="dialog"], [aria-modal="true"]')
) {
checkAndInject(node);
}
// Check if attendee elements were added (Google Calendar might add them dynamically)
if (node.querySelector?.('[role="treeitem"][data-email]')) {
const popup = node.closest('[role="dialog"], [aria-modal="true"], [data-eventid]');
if (popup) {
checkAndInject(popup, true);
}
}
}
});
// Handle attribute changes (Google Calendar might update content dynamically)
if (mutation.type === "attributes" && mutation.target instanceof Element) {
const target = mutation.target;
// Check if this is a popup or inside a popup
const popup = target.closest('[role="dialog"], [aria-modal="true"], [data-eventid]');
if (popup) {
// Only re-inject if buttons are missing
const hasButtons = popup.querySelector(NO_SHOW_BUTTON_SELECTOR);
if (!hasButtons) {
checkAndInject(popup);
}
}
}
// Handle character data changes (text content updates)
if (mutation.type === "characterData" && mutation.target.parentElement) {
const popup = mutation.target.parentElement.closest(
'[role="dialog"], [aria-modal="true"], [data-eventid]'
);
if (popup) {
const hasButtons = popup.querySelector(NO_SHOW_BUTTON_SELECTOR);
if (!hasButtons) {
checkAndInject(popup);
}
}
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "style", "aria-label"],
characterData: true,
});
// Also check for existing popups on page load
setTimeout(() => {
const existingPopups = document.querySelectorAll(
'[data-eventid], [data-eventchip], [role="dialog"][aria-modal="true"]'
);
existingPopups.forEach((popup) => {
checkAndInject(popup);
});
}, 1000);
// Periodically check for popups and verify buttons still exist (fallback)
setInterval(() => {
const allPopups = document.querySelectorAll(
'[role="dialog"], [aria-modal="true"], [data-eventid]'
);
allPopups.forEach((popup) => {
// Only check if popup is visible
const style = window.getComputedStyle(popup);
if (style.display === "none" || style.visibility === "hidden") {
return;
}
// Check if this is a Cal.com event
const text = popup.textContent || "";
const isCalComEvent =
text.includes("cal.com") ||
text.includes("Cal.com") ||
text.includes("booking") ||
extractBookingUid(text) !== null;
if (isCalComEvent) {
// Check if buttons exist and are still attached
const existingButtons = popup.querySelectorAll(NO_SHOW_BUTTON_SELECTOR);
const validButtons = Array.from(existingButtons).filter((btn) => {
const style = window.getComputedStyle(btn);
return btn.isConnected && style.display !== "none" && style.visibility !== "hidden";
});
// If buttons are missing or removed, re-inject
if (validButtons.length === 0) {
checkAndInject(popup, true);
}
}
});
}, 1000);
}
/**
* Initialize Google Calendar integration
*/
export function initGoogleCalendarIntegration(): void {
// Inject styles
injectStyles();
// Start observing for event popups
observeEventPopups();
}