Mode2Test/AuthAndToken: assume tokens always fresh with background refresh; fix jobs API to use correct collection; align Home UI to Mode1 (exact fields, styling, strict Active dot); add full-field IndexedDB cache w/ pagination; remove progress bar; dev TLS bypass; rebuild bundles

This commit is contained in:
2026-01-17 20:20:24 +00:00
parent eed8552282
commit d77fa4b817
21 changed files with 5964 additions and 205 deletions
+14 -14
View File
@@ -1,9 +1,10 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import { getTokens, saveTokens, clearTokens, hasValidPbToken } from './token-service';
import { tokenService } from './token-service';
// Authentication API routes
// Endpoints for frontend to check auth status, get tokens, logout
// Depends on: tokenService singleton (centralized token management)
export function createAuthRoutes(): Hono {
const router = new Hono();
@@ -11,11 +12,13 @@ export function createAuthRoutes(): Hono {
// PERMANENT: Check if user is authenticated
// Returns current auth status and which tokens are available
router.get('/status', async (c: Context) => {
const hasValid = await hasValidPbToken();
const tokens = await tokenService.getTokens();
const hasToken = !!tokens?.pbToken;
return c.json({
authenticated: hasValid,
pbTokenValid: hasValid,
message: hasValid ? 'User authenticated' : 'User not authenticated'
authenticated: hasToken,
pbTokenValid: hasToken,
assumedFresh: hasToken,
message: hasToken ? 'Assuming tokens are valid (auto-refresh running)' : 'No PB token yet; login once'
});
});
@@ -23,17 +26,14 @@ export function createAuthRoutes(): Hono {
// Frontend/backend can call this to get fresh token state
// Only returns tokens if PB token is valid (auth required)
router.get('/tokens', async (c: Context) => {
const hasValid = await hasValidPbToken();
if (!hasValid) {
return c.json({ error: 'Not authenticated' }, 401);
}
const tokens = await getTokens();
const tokens = await tokenService.getTokens();
return c.json({
pbToken: tokens?.pbToken || null,
graphToken: tokens?.graphToken || null,
pbTokenExpiry: tokens?.pbTokenExpiry || null,
graphTokenExpiry: tokens?.graphTokenExpiry || null
graphTokenExpiry: tokens?.graphTokenExpiry || null,
lastRefresh: tokens?.lastRefresh || null,
assumedFresh: !!tokens?.pbToken
});
});
@@ -50,7 +50,7 @@ export function createAuthRoutes(): Hono {
return c.json({ error: 'Missing pbToken' }, 400);
}
await saveTokens(pbToken, graphToken);
await tokenService.saveTokens(pbToken, graphToken);
return c.json({
success: true,
message: 'Login successful',
@@ -66,7 +66,7 @@ export function createAuthRoutes(): Hono {
// Called when user clicks logout
router.post('/logout', async (c: Context) => {
try {
await clearTokens();
await tokenService.clearTokens();
return c.json({
success: true,
message: 'Logged out',
@@ -0,0 +1,104 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import { tokenService } from './token-service';
// PERMANENT: Jobs API routes
// Provides access to job data from PocketBase
// Requires authentication (valid PB token)
//
// Dependencies:
// - tokenService for PB token retrieval
// - process.env.POCKETBASE_URL for PB API endpoint
const JOBS_COLLECTION = 'pbc_1407612689';
//
// Endpoints:
// - GET /api/jobs - List jobs with pagination, filtering, field selection
export function createJobsRoutes(): Hono {
const router = new Hono();
// GET /api/jobs - Fetch jobs from PocketBase
// Query params:
// - page: Page number (default 1)
// - perPage: Items per page (default 50, max 1000)
// - fields: Comma-separated field names to return (optional, for cache optimization)
// - filter: PocketBase filter syntax (optional)
// - sort: Sort field (default -Job_Number)
router.get('/', async (c: Context) => {
try {
// Get token (assume present after login)
const tokens = await tokenService.getTokens();
const pbToken = tokens?.pbToken;
// Parse query params
const page = parseInt(c.req.query('page') || '1');
const perPage = Math.min(parseInt(c.req.query('perPage') || '50'), 1000);
const fields = c.req.query('fields'); // Optional: comma-separated field names
const filter = c.req.query('filter'); // Optional: PocketBase filter
const sort = c.req.query('sort') || '-Job_Number';
// Build PocketBase API URL
const pbUrl = new URL(`${process.env.POCKETBASE_URL}/api/collections/${JOBS_COLLECTION}/records`);
pbUrl.searchParams.set('page', page.toString());
pbUrl.searchParams.set('perPage', perPage.toString());
pbUrl.searchParams.set('sort', sort);
if (fields) {
pbUrl.searchParams.set('fields', fields);
}
if (filter) {
pbUrl.searchParams.set('filter', filter);
}
// Fetch from PocketBase
// temporary: dev-only SSL bypass; global rejectUnauthorized already set, keep here as explicit
const response = await fetch(pbUrl.toString(), {
headers: pbToken ? { 'Authorization': `Bearer ${pbToken}` } : {},
// @ts-ignore Bun TLS option
tls: { rejectUnauthorized: false }
});
if (!response.ok) {
const errorText = await response.text();
console.error('PocketBase jobs fetch failed:', response.status, errorText);
return c.json({ error: 'Failed to fetch jobs from PocketBase' }, response.status);
}
const data = await response.json();
return c.json(data);
} catch (err) {
console.error('Jobs API error:', (err as Error)?.message);
return c.json({ error: 'Internal server error' }, 500);
}
});
// GET /api/jobs/:id - Fetch single job by ID
router.get('/:id', async (c: Context) => {
try {
// Check authentication
const tokens = await tokenService.getTokens();
const pbToken = tokens?.pbToken;
const jobId = c.req.param('id');
const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/${JOBS_COLLECTION}/records/${jobId}`;
// Fetch from PocketBase
const response = await fetch(pbUrl, {
headers: pbToken ? { 'Authorization': `Bearer ${pbToken}` } : {}
});
if (!response.ok) {
return c.json({ error: 'Job not found' }, 404);
}
const data = await response.json();
return c.json(data);
} catch (err) {
console.error('Job fetch error:', (err as Error)?.message);
return c.json({ error: 'Internal server error' }, 500);
}
});
return router;
}
+17 -3
View File
@@ -1,14 +1,25 @@
import { Hono } from 'hono';
import { serveStatic } from 'hono/bun';
import { join } from 'path';
import { initializeTokenService, startBackgroundRefresh } from './token-service';
import { tokenService } from './token-service';
import { createAuthRoutes } from './auth-routes';
import { createJobsRoutes } from './jobs-routes';
// temporary: dev-only SSL bypass for upstream PocketBase cert issues
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
// PERMANENT: Load environment variables
// Check if POCKETBASE_URL is set, use default if not
if (!process.env.POCKETBASE_URL) {
process.env.POCKETBASE_URL = 'https://pocketbase.ccllc.pro';
console.log('[Mode2Test] Using default POCKETBASE_URL:', process.env.POCKETBASE_URL);
}
// PERMANENT: Initialize token service and start background refresh
// Loads existing tokens from file if available
// Starts 5-minute background refresh cycle
initializeTokenService();
startBackgroundRefresh();
tokenService.initialize();
tokenService.startBackgroundRefresh();
// Create Hono app
const app = new Hono();
@@ -16,6 +27,9 @@ const app = new Hono();
// Mount auth routes
app.route('/api/auth', createAuthRoutes());
// Mount jobs routes
app.route('/api/jobs', createJobsRoutes());
// Health check endpoint
app.get('/health', (c) => {
return c.text('OK');
+150 -118
View File
@@ -1,8 +1,20 @@
import { join } from 'path';
// Token storage and refresh management
// Stores both PocketBase and Graph tokens locally (tokens.json)
// Background refresh keeps both tokens fresh without user intervention
// PERMANENT: Token Service Module
// Singleton module for centralized token acquisition, storage, and refresh
// Available to any part of the application via: import { tokenService } from './token-service.ts'
//
// Token Lifecycle:
// - Tokens stored in tokens.json (persistent across restarts)
// - Never delete old token before new one acquired
// - Background refresh every 5 minutes
// - Startup: if PB token exists, resume refresh; else require login
//
// Usage:
// const tokens = await tokenService.getTokens();
// await tokenService.saveTokens(pbToken, graphToken);
// await tokenService.initialize();
// tokenService.startBackgroundRefresh();
interface TokenData {
pbToken: string;
@@ -14,131 +26,151 @@ interface TokenData {
const tokensFilePath = join(import.meta.dir, '../../tokens.json');
// PERMANENT: Token lifecycle management
// - Tokens stored in tokens.json (persistent across restarts)
// - Never delete old token before new one acquired
// - Background refresh every 5 minutes
// - Startup: if PB token exists, resume refresh; else require login
class TokenService {
private backgroundStarted = false;
export async function initializeTokenService(): Promise<void> {
// Load tokens from file if they exist
try {
const file = Bun.file(tokensFilePath);
if (await file.exists()) {
const content = await file.text();
const data = JSON.parse(content) as TokenData;
console.log('[AuthAndToken] Tokens loaded from file');
console.log('[AuthAndToken] PB token valid:', data.pbToken ? 'yes' : 'no');
console.log('[AuthAndToken] Graph token valid:', data.graphToken ? 'yes' : 'no');
} else {
console.log('[AuthAndToken] No tokens.json found - login required on startup');
}
} catch (err) {
console.log('[AuthAndToken] Token file init error (will create on first login):', (err as Error)?.message);
}
}
export function startBackgroundRefresh(): void {
// PERMANENT: Background token refresh
// Runs every 5 minutes, attempts to refresh both PB and Graph tokens
// Safe: never deletes old token before new one is confirmed
setInterval(async () => {
async initialize(): Promise<void> {
// Load tokens from file if they exist
try {
await refreshTokens();
} catch (err) {
console.log('[AuthAndToken] Background refresh error:', (err as Error)?.message);
}
}, 5 * 60 * 1000); // 5 minutes
console.log('[AuthAndToken] Background refresh started (every 5 minutes)');
}
export async function saveTokens(pbToken: string, graphToken?: string): Promise<void> {
// PERMANENT: Token persistence
// Called after login or successful refresh
// Stores both tokens with expiry times
const data: TokenData = {
pbToken,
pbTokenExpiry: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days (PB default)
graphToken,
graphTokenExpiry: graphToken ? Date.now() + 60 * 60 * 1000 : undefined, // 1 hour (Graph default)
lastRefresh: Date.now()
};
await Bun.write(tokensFilePath, JSON.stringify(data, null, 2));
console.log('[AuthAndToken] Tokens saved');
}
export async function getTokens(): Promise<TokenData | null> {
try {
const file = Bun.file(tokensFilePath);
if (!(await file.exists())) return null;
const content = await file.text();
return JSON.parse(content) as TokenData;
} catch (err) {
console.log('[AuthAndToken] Error reading tokens:', (err as Error)?.message);
return null;
}
}
export async function clearTokens(): Promise<void> {
try {
const file = Bun.file(tokensFilePath);
if (await file.exists()) {
await Bun.write(tokensFilePath, '');
}
} catch (err) {
console.log('[AuthAndToken] Error clearing tokens:', (err as Error)?.message);
}
}
async function refreshTokens(): Promise<void> {
// PERMANENT: Auto-refresh logic for both tokens
// Called every 5 minutes by background job
// Fetches fresh tokens from PocketBase, attempts Graph token refresh
// Only updates file after new tokens confirmed valid
const tokens = await getTokens();
if (!tokens || !tokens.pbToken) {
console.log('[AuthAndToken] No active tokens to refresh');
return;
}
try {
// Refresh via PocketBase endpoint
const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/users/auth-refresh`;
const response = await fetch(pbUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokens.pbToken}`
const file = Bun.file(tokensFilePath);
if (await file.exists()) {
const content = await file.text();
const data = JSON.parse(content) as TokenData;
console.log('[AuthAndToken] Tokens loaded from file');
console.log('[AuthAndToken] PB token valid:', data.pbToken ? 'yes' : 'no');
console.log('[AuthAndToken] Graph token valid:', data.graphToken ? 'yes' : 'no');
} else {
console.log('[AuthAndToken] No tokens.json found - login required on startup');
}
} catch (err) {
console.log('[AuthAndToken] Token file init error (will create on first login):', (err as Error)?.message);
}
}
startBackgroundRefresh(): void {
if (this.backgroundStarted) return;
this.backgroundStarted = true;
// PERMANENT: Background token refresh
// Runs every 5 minutes, attempts to refresh both PB and Graph tokens
// Safe: never deletes old token before new one is confirmed
setInterval(async () => {
try {
await this.refreshTokens();
} catch (err) {
console.log('[AuthAndToken] Background refresh error:', (err as Error)?.message);
}
}, 5 * 60 * 1000); // 5 minutes
// Kick off an immediate refresh attempt so we start from a fresh token without waiting
this.refreshTokens().catch((err) => {
console.log('[AuthAndToken] Immediate refresh error:', (err as Error)?.message);
});
if (!response.ok) {
console.log('[AuthAndToken] Refresh failed:', response.status);
console.log('[AuthAndToken] Background refresh started (every 5 minutes + immediate)');
}
async saveTokens(pbToken: string, graphToken?: string): Promise<void> {
// PERMANENT: Token persistence
// Called after login or successful refresh
// Stores both tokens with expiry times
const data: TokenData = {
pbToken,
pbTokenExpiry: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days (PB default)
graphToken,
graphTokenExpiry: graphToken ? Date.now() + 60 * 60 * 1000 : undefined, // 1 hour (Graph default)
lastRefresh: Date.now()
};
await Bun.write(tokensFilePath, JSON.stringify(data, null, 2));
console.log('[AuthAndToken] Tokens saved');
}
async getTokens(): Promise<TokenData | null> {
try {
const file = Bun.file(tokensFilePath);
if (!(await file.exists())) return null;
const content = await file.text();
return JSON.parse(content) as TokenData;
} catch (err) {
console.log('[AuthAndToken] Error reading tokens:', (err as Error)?.message);
try {
const fs = await import('fs/promises');
await fs.unlink(tokensFilePath);
console.log('[AuthAndToken] Corrupt tokens file removed');
} catch (innerErr) {
console.log('[AuthAndToken] Failed to remove corrupt tokens file:', (innerErr as Error)?.message);
}
return null;
}
}
async clearTokens(): Promise<void> {
try {
const file = Bun.file(tokensFilePath);
if (await file.exists()) {
const fs = await import('fs/promises');
await fs.unlink(tokensFilePath);
}
} catch (err) {
console.log('[AuthAndToken] Error clearing tokens:', (err as Error)?.message);
}
}
async hasValidPbToken(): Promise<boolean> {
// Simplified: treat presence of PB token as valid; no expiry enforcement
const tokens = await this.getTokens();
return !!tokens?.pbToken;
}
private async refreshTokens(): Promise<void> {
// PERMANENT: Auto-refresh logic for both tokens
// Called every 5 minutes by background job
// Fetches fresh tokens from PocketBase, attempts Graph token refresh
// Only updates file after new tokens confirmed valid
const tokens = await this.getTokens();
if (!tokens || !tokens.pbToken) {
console.log('[AuthAndToken] No active tokens to refresh');
return;
}
const data = await response.json() as any;
const newPbToken = data?.token;
try {
// Refresh via PocketBase endpoint
const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/users/auth-refresh`;
const response = await fetch(pbUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokens.pbToken}`
},
// @ts-ignore Bun TLS option
tls: { rejectUnauthorized: false }
});
if (!newPbToken) {
console.log('[AuthAndToken] No new PB token in response');
return;
if (!response.ok) {
console.log('[AuthAndToken] Refresh failed:', response.status);
return;
}
const data = await response.json() as any;
const newPbToken = data?.token;
if (!newPbToken) {
console.log('[AuthAndToken] No new PB token in response');
return;
}
// Update tokens with new PB token
await this.saveTokens(newPbToken, tokens.graphToken);
console.log('[AuthAndToken] Tokens refreshed successfully');
} catch (err) {
console.log('[AuthAndToken] Refresh error:', (err as Error)?.message);
}
// Update tokens with new PB token
await saveTokens(newPbToken, tokens.graphToken);
console.log('[AuthAndToken] Tokens refreshed successfully');
} catch (err) {
console.log('[AuthAndToken] Refresh error:', (err as Error)?.message);
}
}
export async function hasValidPbToken(): Promise<boolean> {
const tokens = await getTokens();
return !!tokens?.pbToken && tokens.pbTokenExpiry > Date.now();
}
// PERMANENT: Singleton export
// Import as: import { tokenService } from './token-service.ts'
export const tokenService = new TokenService();
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Job Details</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="font-sans bg-gray-100 p-4">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-4 text-blue-600">Job Details</h1>
<p class="text-gray-600">Details view - Under construction</p>
<a href="/home.html" class="text-blue-600 underline mt-4 inline-block">← Back to Home</a>
</div>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" maximum-scale="1" />
<title>Job Info — Home</title>
<link rel="preload" href="assets/CCwhiteApp.png" as="image">
<script src="https://cdn.tailwindcss.com"></script>
<script>
if (window.tailwind) window.tailwind.config = { corePlugins: { preflight: true } }
</script>
</head>
<body class="font-sans bg-gray-100 m-0 text-gray-900 overflow-hidden touch-pan-y h-screen flex flex-col">
<div class="max-w-full md:max-w-2xl mx-auto p-4 sm:p-3 text-[17px] flex flex-col flex-1 overflow-hidden">
<!-- Header -->
<div class="w-full bg-gray-400 border border-gray-300 rounded-lg px-0.5 py-0.5 mb-3 shadow-sm flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="h-6 w-[186px] rounded-lg bg-black flex items-center justify-center overflow-hidden">
<img src="assets/CCwhiteApp.png" alt="CC" class="h-6 w-auto object-cover scale-80" loading="lazy">
</div>
</div>
<div class="flex items-center gap-0">
<a href="https://idea-form.ccllc.pro" target="_blank" rel="noopener" class="h-6 w-12 rounded-3xl overflow-hidden flex items-center justify-center bg-transparent hover:bg-gray-50/20" title="Submit an idea">
<img src="assets/lightbulb.jpg" alt="Idea" class="h-full w-auto object-cover" loading="lazy">
</a>
<a href="https://czflex.sharepoint.com/:x:/r/sites/Team/Shared%20Documents/General/ALFONSO/Comprehensive%20Asset%20Inventory.xlsx?d=w9005440917e945d680e9885f33ccf2ba&csf=1&web=1&e=rifTzf" target="_blank" rel="noopener" class="h-6 w-12 rounded-3xl overflow-hidden flex items-center justify-center bg-transparent hover:bg-gray-50/20" title="Open Asset Inventory">
<img src="assets/PalmIsland.png" alt="Palm Island" class="h-full w-auto object-cover" loading="lazy">
</a>
<a href="https://czflex.sharepoint.com/:x:/r/sites/Team/Shared%20Documents/General/ALFONSO/GM%20Rollup.xlsx?d=w3e59f37ee1c04cb798e14f86bd87c968&csf=1&web=1&e=vCnfbR" target="_blank" rel="noopener" class="h-6 w-12 rounded-3xl overflow-hidden flex items-center justify-center bg-transparent hover:bg-gray-50/20" title="Open Operations Dashboard">
<img src="assets/DailyCheck.png" alt="Operations Dashboard" class="h-full w-auto object-cover" loading="lazy">
</a>
</div>
</div>
<h1 class="m-0 mb-3 text-center text-blue-600 text-[33px] font-bold">Job Info</h1>
<!-- Search Box -->
<div class="relative mb-2">
<input id="searchBox" placeholder="Search jobs (just start typing or use voice)" class="w-full p-2.5 pr-12 rounded-lg border border-gray-300 text-lg placeholder:text-gray-400">
<button id="voiceSearchBtn" class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full flex items-center justify-center hover:bg-gray-100 transition-colors" title="Voice search">
<svg id="micIcon" class="w-5 h-5 text-gray-600 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
</svg>
<svg id="micRecordingIcon" class="hidden w-5 h-5 text-red-600 animate-pulse" fill="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8"></circle>
</svg>
</button>
</div>
<!-- Filter Toggle -->
<button id="filterToggle" class="bg-blue-600 text-white py-2 px-2.5 rounded-lg border-none cursor-pointer mb-2 inline-block min-w-[140px]">Show Filters</button>
<!-- Filters Panel -->
<div id="filtersPanel" class="hidden bg-white border border-gray-200 rounded-lg p-2.5 mb-2.5">
<div class="mb-2 text-sm">
<strong>Division</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="C#"> Commercial</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="R#"> Residential</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-division" value="S#"> Small Jobs</label>
</div>
<div class="mb-2 text-sm">
<strong>Active</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-active" value="Yes"> Yes</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-active" value="No"> No</label>
</div>
<div class="mb-2 text-sm">
<strong>Estimator</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-estimator" value="Steve Brewer"> Steve</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-estimator" value="Beth Cardoza"> Beth</label>
</div>
<div class="mb-2 text-sm">
<strong>Status</strong><br/>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Estimating"> Estimating</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="In Progress"> In Progress</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Awarded"> Awarded</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Billed (In Progress)"> Billed (In Progress)</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Not Bidding"> Not Bidding</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Est Sent"> Est Sent</label>
<label class="mr-2 cursor-pointer text-gray-500"><input type="checkbox" class="filter-status" value="Not Awarded"> Not Awarded</label>
</div>
<button id="clearFilters" class="bg-gray-500 text-white py-2 px-2 rounded-lg border-none">Clear Filters</button>
</div>
<!-- Results -->
<div id="results" aria-live="polite" class="flex flex-col gap-3 flex-1 overflow-auto p-0"></div>
<!-- Footer -->
<div class="text-xs text-gray-500 mt-5 pt-3 border-t border-gray-200 flex items-center justify-between">
<span id="userEmail" class="truncate"></span>
<div class="flex items-center gap-2">
<button id="signOutBtn" class="px-2 py-1 text-[11px] bg-gray-200 hover:bg-gray-300 rounded border border-gray-300 text-gray-700">Sign out</button>
<span id="modeLabel" class="px-2 py-1 text-[11px] bg-blue-100 text-blue-700 rounded border border-blue-300">[Mode2Test]</span>
<span id="versionLabel">v1.0.0-beta1</span>
</div>
</div>
</div>
<script type="module" src="js/home.js?v=2"></script>
</body>
</html>
+28 -43
View File
@@ -4,50 +4,35 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mode2Test</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>
</head>
<body class="font-sans bg-gray-50 min-h-screen">
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold text-gray-800">Mode2Test</h1>
<p class="text-sm text-gray-600">Authenticated Application</p>
</div>
<div class="flex items-center gap-4">
<div id="userDisplay" class="text-right">
<p id="userName" class="text-sm font-medium text-gray-800"></p>
<p id="userEmail" class="text-xs text-gray-600"></p>
</div>
<button onclick="handleLogout()" class="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700">
Logout
</button>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 py-8">
<div class="bg-white p-8 rounded-lg shadow">
<h2 class="text-xl font-bold mb-4 text-gray-800">Welcome to Mode2Test</h2>
<p class="text-gray-600 mb-6">Authenticated and ready to work.</p>
<div class="bg-gray-50 p-4 rounded border border-gray-200">
<h3 class="font-medium mb-2 text-gray-800">Token Status</h3>
<div id="tokenStatus" class="text-sm text-gray-600 space-y-2">
<p>Loading...</p>
</div>
</div>
</div>
</main>
<script>
const pb = new PocketBase('https://pocketbase.ccllc.pro');
async function checkAuth() {
if (!pb.authStore.isValid) {
// Try to refresh
try {
await pb.collection('users').authRefresh();
// Redirect to appropriate page based on auth status
async function checkAuthAndRedirect() {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
if (data.authenticated) {
// Redirect to home
window.location.href = '/home.html';
} else {
// Redirect to signin
window.location.href = '/signin.html';
}
} catch (err) {
// On error, go to signin
window.location.href = '/signin.html';
}
}
checkAuthAndRedirect();
</script>
</head>
<body>
<div style="text-align: center; padding: 50px;">
<p>Redirecting...</p>
</div>
</body>
</html>
} catch {
window.location.href = '/signin.html';
return;
+344
View File
@@ -0,0 +1,344 @@
// job-cache.js
var DB_NAME = "JobInfoCache";
var DB_VERSION = 2;
var JOBS_STORE = "jobs";
var META_STORE = "metadata";
var CACHE_TTL = 5 * 60 * 1000;
var PAGE_SIZE = 500;
class JobCache {
db = null;
async initialize() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(JOBS_STORE)) {
db.createObjectStore(JOBS_STORE, { keyPath: "Job_Number" });
}
if (!db.objectStoreNames.contains(META_STORE)) {
db.createObjectStore(META_STORE);
}
};
});
}
async getCachedJobs() {
if (!this.db)
throw new Error("Database not initialized");
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([JOBS_STORE], "readonly");
const store = transaction.objectStore(JOBS_STORE);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async isCacheValid() {
if (!this.db)
return false;
try {
const metadata = await this.getMetadata();
if (!metadata || !metadata.lastUpdated)
return false;
const age = Date.now() - metadata.lastUpdated;
return age < CACHE_TTL;
} catch {
return false;
}
}
async saveJobs(jobs) {
if (!this.db)
throw new Error("Database not initialized");
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
const jobsStore = transaction.objectStore(JOBS_STORE);
const metaStore = transaction.objectStore(META_STORE);
jobsStore.clear();
jobs.forEach((job) => jobsStore.add(job));
const metadata = {
lastUpdated: Date.now(),
version: DB_VERSION
};
metaStore.put(metadata, "main");
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async refreshJobs() {
try {
let page = 1;
let all = [];
let totalPages = 1;
while (page <= totalPages) {
const url = `/api/jobs?page=${page}&perPage=${PAGE_SIZE}&sort=-Job_Number`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch jobs (status ${response.status})`);
}
const data = await response.json();
const items = data.items || [];
const reportedTotalPages = data.totalPages || 1;
all = all.concat(items);
totalPages = Math.max(totalPages, reportedTotalPages);
page += 1;
}
await this.saveJobs(all);
return all;
} catch (err) {
console.error("Failed to refresh jobs cache:", err);
throw err;
}
}
async getMetadata() {
if (!this.db)
return null;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([META_STORE], "readonly");
const store = transaction.objectStore(META_STORE);
const request = store.get("main");
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
async clearCache() {
if (!this.db)
throw new Error("Database not initialized");
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
transaction.objectStore(JOBS_STORE).clear();
transaction.objectStore(META_STORE).clear();
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async getCacheAge() {
const metadata = await this.getMetadata();
if (!metadata || !metadata.lastUpdated)
return null;
return Date.now() - metadata.lastUpdated;
}
}
var jobCache = new JobCache;
// home.ts
var allJobs = [];
var filteredJobs = [];
var currentPage = 1;
var perPage = 50;
var isRefreshing = false;
async function checkAuth() {
try {
const response = await fetch("/api/auth/status");
const data = await response.json();
if (!data.authenticated) {
window.location.href = "/signin.html";
return false;
}
return true;
} catch (err) {
console.error("Auth check failed:", err);
window.location.href = "/signin.html";
return false;
}
}
async function fetchJobs() {
try {
await jobCache.initialize();
const isCacheValid = await jobCache.isCacheValid();
if (isCacheValid) {
const cachedJobs = await jobCache.getCachedJobs();
allJobs = cachedJobs;
filteredJobs = [...allJobs];
renderJobs();
const cacheAge = await jobCache.getCacheAge();
console.log(`Loaded ${allJobs.length} jobs from cache (age: ${Math.round((cacheAge || 0) / 1000)}s)`);
refreshJobsInBackground();
} else {
const jobs = await jobCache.refreshJobs();
allJobs = jobs;
filteredJobs = [...allJobs];
renderJobs();
console.log(`Loaded ${allJobs.length} jobs from server (cache updated)`);
}
} catch (err) {
console.error("Failed to fetch jobs:", err);
const results = document.getElementById("results");
results.innerHTML = '<div class="text-center text-red-600 p-4">Failed to load jobs. Please refresh the page.</div>';
}
}
async function refreshJobsInBackground() {
if (isRefreshing)
return;
isRefreshing = true;
try {
const freshJobs = await jobCache.refreshJobs();
allJobs = freshJobs;
applyFilters();
console.log(`Background refresh complete (${freshJobs.length} jobs)`);
} catch (err) {
console.error("Background refresh failed:", err);
} finally {
isRefreshing = false;
}
}
function renderJobs() {
const results = document.getElementById("results");
if (filteredJobs.length === 0) {
results.innerHTML = '<div class="text-center text-gray-500 p-4">No jobs found</div>';
return;
}
const start = (currentPage - 1) * perPage;
const end = start + perPage;
const pageJobs = filteredJobs.slice(start, end);
results.innerHTML = pageJobs.map((job) => {
const isActiveTrue = job.Active === true;
const isActiveFalse = job.Active === false;
const nameVal = job.Job_Full_Name || "";
const contactVal = job.Contact_Person || "";
return `
<div class="bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1 hover:bg-gray-50 transition-colors" data-job-number="${escapeHtml(String(job.Job_Number || ""))}">
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job#</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(String(job.Job_Number || ""))}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job Name:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(String(nameVal))}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Contact:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(String(contactVal))}</div></div>
<div class="absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm" style="background:${isActiveTrue ? "green" : isActiveFalse ? "red" : "#cbd5e1"}"></div>
</div>
`;
}).join("");
results.querySelectorAll("[data-job-number]").forEach((el) => {
el.addEventListener("click", (e) => {
const jobNumber = e.currentTarget.dataset.jobNumber;
if (jobNumber) {
openDetails(jobNumber);
}
});
});
}
function openDetails(jobNumber) {
window.location.href = `/details.html?job=${encodeURIComponent(jobNumber)}`;
}
function setupSearch() {
const searchBox = document.getElementById("searchBox");
searchBox.addEventListener("input", () => {
const query = searchBox.value.toLowerCase().trim();
if (!query) {
filteredJobs = [...allJobs];
} else {
filteredJobs = allJobs.filter((job) => {
return job.Job_Number?.toString().toLowerCase().includes(query) || job.Job_Full_Name?.toLowerCase().includes(query) || job.Job_Address?.toLowerCase().includes(query) || job.Contact_Person?.toLowerCase().includes(query) || job.Company_Client?.toLowerCase().includes(query) || job.Notes?.toLowerCase().includes(query) || job.Job_Status?.toLowerCase().includes(query) || job.Status?.toLowerCase().includes(query) || job.Estimator?.toLowerCase().includes(query);
});
}
currentPage = 1;
renderJobs();
});
}
function setupFilters() {
const filterToggle = document.getElementById("filterToggle");
const filtersPanel = document.getElementById("filtersPanel");
const clearFilters = document.getElementById("clearFilters");
filterToggle.addEventListener("click", () => {
const isHidden = filtersPanel.classList.contains("hidden");
if (isHidden) {
filtersPanel.classList.remove("hidden");
filterToggle.textContent = "Hide Filters";
} else {
filtersPanel.classList.add("hidden");
filterToggle.textContent = "Show Filters";
}
});
const filterInputs = filtersPanel.querySelectorAll('input[type="checkbox"]');
filterInputs.forEach((input) => {
input.addEventListener("change", applyFilters);
});
clearFilters.addEventListener("click", () => {
filterInputs.forEach((input) => {
input.checked = false;
});
applyFilters();
});
}
function applyFilters() {
const divisions = Array.from(document.querySelectorAll(".filter-division:checked")).map((el) => el.value);
const actives = Array.from(document.querySelectorAll(".filter-active:checked")).map((el) => el.value);
const estimators = Array.from(document.querySelectorAll(".filter-estimator:checked")).map((el) => el.value.toLowerCase());
const statuses = Array.from(document.querySelectorAll(".filter-status:checked")).map((el) => el.value);
filteredJobs = allJobs.filter((job) => {
const divisionVal = job.Job_Division || job.Division;
const activeVal = job.Active === true || job.Active === "Yes" ? "Yes" : job.Active === false || job.Active === "No" ? "No" : "—";
const estimatorVal = (job.Estimator || "").toLowerCase();
const statusVal = job.Job_Status || job.Status;
if (divisions.length > 0 && !divisions.some((d) => divisionVal?.startsWith(d)))
return false;
if (actives.length > 0 && !actives.includes(activeVal))
return false;
if (estimators.length > 0 && !estimators.some((e) => estimatorVal.includes(e)))
return false;
if (statuses.length > 0 && !statuses.includes(statusVal || ""))
return false;
return true;
});
currentPage = 1;
renderJobs();
}
function setupVoiceSearch() {
const voiceBtn = document.getElementById("voiceSearchBtn");
const micIcon = document.getElementById("micIcon");
const micRecordingIcon = document.getElementById("micRecordingIcon");
const searchBox = document.getElementById("searchBox");
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
voiceBtn.style.display = "none";
return;
}
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition;
recognition.continuous = false;
recognition.interimResults = false;
voiceBtn.addEventListener("click", () => {
recognition.start();
micIcon.classList.add("hidden");
micRecordingIcon.classList.remove("hidden");
});
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
searchBox.value = transcript;
searchBox.dispatchEvent(new Event("input"));
};
recognition.onend = () => {
micIcon.classList.remove("hidden");
micRecordingIcon.classList.add("hidden");
};
recognition.onerror = () => {
micIcon.classList.remove("hidden");
micRecordingIcon.classList.add("hidden");
};
}
function setupSignOut() {
const signOutBtn = document.getElementById("signOutBtn");
signOutBtn.addEventListener("click", async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
window.location.href = "/signin.html";
} catch (err) {
console.error("Sign out failed:", err);
window.location.href = "/signin.html";
}
});
}
async function init() {
const isAuthenticated = await checkAuth();
if (!isAuthenticated)
return;
setupSearch();
setupFilters();
setupVoiceSearch();
setupSignOut();
await fetchJobs();
}
function escapeHtml(str) {
return str.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
init();
+330
View File
@@ -0,0 +1,330 @@
// PERMANENT: Home view - Jobs listing with zero-delay loading via IndexedDB cache
// Wired to tokenService via backend API routes
// Visual match to Mode1Test but uses new architecture
//
// Dependencies:
// - Backend /api/auth/status for authentication check
// - Backend /api/jobs for job data
// - job-cache.ts for IndexedDB caching
//
// Features:
// - Zero-delay load (displays cached jobs instantly)
// - Background refresh after displaying cache
// - Search with voice support (operates on cached data)
// - Filters (division, active, estimator, status)
// - Pagination
// - Click job to open Details view
import { jobCache, type JobCard } from './job-cache.js';
interface Job {
Job_Number: string;
Job_Name?: string;
Job_Full_Name?: string;
Job_Address?: string;
Job_Division?: string;
Active?: boolean | string;
Status?: string;
Job_Status?: string;
Estimator?: string;
Contact_Person?: string;
Company_Client?: string;
[key: string]: any;
}
let allJobs: Job[] = [];
let filteredJobs: Job[] = [];
let currentPage = 1;
const perPage = 50;
let isRefreshing = false;
// Check authentication on page load
async function checkAuth(): Promise<boolean> {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
if (!data.authenticated) {
// Redirect to login
window.location.href = '/signin.html';
return false;
}
return true;
} catch (err) {
console.error('Auth check failed:', err);
window.location.href = '/signin.html';
return false;
}
}
// Fetch jobs from backend (or cache)
async function fetchJobs(): Promise<void> {
try {
// Initialize cache
await jobCache.initialize();
// Check if cache is valid
const isCacheValid = await jobCache.isCacheValid();
if (isCacheValid) {
// Display cached jobs immediately (zero delay)
const cachedJobs = await jobCache.getCachedJobs();
allJobs = cachedJobs;
filteredJobs = [...allJobs];
renderJobs();
// Show cache age in console for debugging
const cacheAge = await jobCache.getCacheAge();
console.log(`Loaded ${allJobs.length} jobs from cache (age: ${Math.round((cacheAge || 0) / 1000)}s)`);
// Refresh in background (don't block UI)
refreshJobsInBackground();
} else {
// No valid cache, fetch fresh data (no progress bar)
const jobs = await jobCache.refreshJobs();
allJobs = jobs;
filteredJobs = [...allJobs];
renderJobs();
console.log(`Loaded ${allJobs.length} jobs from server (cache updated)`);
}
} catch (err) {
console.error('Failed to fetch jobs:', err);
// Show error message
const results = document.getElementById('results') as HTMLDivElement;
results.innerHTML = '<div class="text-center text-red-600 p-4">Failed to load jobs. Please refresh the page.</div>';
}
}
// Background refresh (silent, doesn't interrupt user)
async function refreshJobsInBackground(): Promise<void> {
if (isRefreshing) return;
isRefreshing = true;
try {
const freshJobs = await jobCache.refreshJobs();
// Update in memory without interrupting user
allJobs = freshJobs;
// Reapply current filters and search
applyFilters();
console.log(`Background refresh complete (${freshJobs.length} jobs)`);
} catch (err) {
console.error('Background refresh failed:', err);
} finally {
isRefreshing = false;
}
}
// Render jobs to DOM
function renderJobs(): void {
const results = document.getElementById('results') as HTMLDivElement;
if (filteredJobs.length === 0) {
results.innerHTML = '<div class="text-center text-gray-500 p-4">No jobs found</div>';
return;
}
const start = (currentPage - 1) * perPage;
const end = start + perPage;
const pageJobs = filteredJobs.slice(start, end);
results.innerHTML = pageJobs.map(job => {
const isActiveTrue = job.Active === true;
const isActiveFalse = job.Active === false;
const nameVal = job.Job_Full_Name || '';
const contactVal = job.Contact_Person || '';
return `
<div class="bg-white rounded-lg p-2.5 border border-blue-100 shadow-sm cursor-pointer relative flex flex-col justify-center gap-1 hover:bg-gray-50 transition-colors" data-job-number="${escapeHtml(String(job.Job_Number||''))}">
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job#</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(String(job.Job_Number||''))}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Job Name:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(String(nameVal))}</div></div>
<div class="flex items-center gap-2 text-lg leading-tight mb-0.5"><div class="font-bold text-gray-700 min-w-[72px] whitespace-nowrap">Contact:</div><div class="text-gray-900 overflow-hidden text-ellipsis whitespace-nowrap flex-1">${escapeHtml(String(contactVal))}</div></div>
<div class="absolute top-2.5 right-2.5 w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm" style="background:${isActiveTrue?'green':(isActiveFalse?'red':'#cbd5e1')}"></div>
</div>
`;
}).join('');
// Add click handlers
results.querySelectorAll('[data-job-number]').forEach(el => {
el.addEventListener('click', (e) => {
const jobNumber = (e.currentTarget as HTMLElement).dataset.jobNumber;
if (jobNumber) {
openDetails(jobNumber);
}
});
});
}
// Open Details view
function openDetails(jobNumber: string): void {
// Navigate to details view with job number
window.location.href = `/details.html?job=${encodeURIComponent(jobNumber)}`;
}
// Search functionality
function setupSearch(): void {
const searchBox = document.getElementById('searchBox') as HTMLInputElement;
searchBox.addEventListener('input', () => {
const query = searchBox.value.toLowerCase().trim();
if (!query) {
filteredJobs = [...allJobs];
} else {
filteredJobs = allJobs.filter(job => {
return (
job.Job_Number?.toString().toLowerCase().includes(query) ||
job.Job_Full_Name?.toLowerCase().includes(query) ||
job.Job_Address?.toLowerCase().includes(query) ||
job.Contact_Person?.toLowerCase().includes(query) ||
job.Company_Client?.toLowerCase().includes(query) ||
job.Notes?.toLowerCase().includes(query) ||
job.Job_Status?.toLowerCase().includes(query) ||
job.Status?.toLowerCase().includes(query) ||
job.Estimator?.toLowerCase().includes(query)
);
});
}
currentPage = 1;
renderJobs();
});
}
// Filter functionality
function setupFilters(): void {
const filterToggle = document.getElementById('filterToggle') as HTMLButtonElement;
const filtersPanel = document.getElementById('filtersPanel') as HTMLDivElement;
const clearFilters = document.getElementById('clearFilters') as HTMLButtonElement;
filterToggle.addEventListener('click', () => {
const isHidden = filtersPanel.classList.contains('hidden');
if (isHidden) {
filtersPanel.classList.remove('hidden');
filterToggle.textContent = 'Hide Filters';
} else {
filtersPanel.classList.add('hidden');
filterToggle.textContent = 'Show Filters';
}
});
// Apply filters on change
const filterInputs = filtersPanel.querySelectorAll('input[type="checkbox"]');
filterInputs.forEach(input => {
input.addEventListener('change', applyFilters);
});
clearFilters.addEventListener('click', () => {
filterInputs.forEach(input => {
(input as HTMLInputElement).checked = false;
});
applyFilters();
});
}
function applyFilters(): void {
const divisions = Array.from(document.querySelectorAll('.filter-division:checked')).map(el => (el as HTMLInputElement).value);
const actives = Array.from(document.querySelectorAll('.filter-active:checked')).map(el => (el as HTMLInputElement).value);
const estimators = Array.from(document.querySelectorAll('.filter-estimator:checked')).map(el => (el as HTMLInputElement).value.toLowerCase());
const statuses = Array.from(document.querySelectorAll('.filter-status:checked')).map(el => (el as HTMLInputElement).value);
filteredJobs = allJobs.filter(job => {
const divisionVal = job.Job_Division || job.Division;
const activeVal = job.Active === true || job.Active === 'Yes' ? 'Yes' : job.Active === false || job.Active === 'No' ? 'No' : '—';
const estimatorVal = (job.Estimator || '').toLowerCase();
const statusVal = job.Job_Status || job.Status;
if (divisions.length > 0 && !divisions.some(d => divisionVal?.startsWith(d))) return false;
if (actives.length > 0 && !actives.includes(activeVal)) return false;
if (estimators.length > 0 && !estimators.some(e => estimatorVal.includes(e))) return false;
if (statuses.length > 0 && !statuses.includes(statusVal || '')) return false;
return true;
});
currentPage = 1;
renderJobs();
}
// Voice search
function setupVoiceSearch(): void {
const voiceBtn = document.getElementById('voiceSearchBtn') as HTMLButtonElement;
const micIcon = document.getElementById('micIcon') as SVGElement;
const micRecordingIcon = document.getElementById('micRecordingIcon') as SVGElement;
const searchBox = document.getElementById('searchBox') as HTMLInputElement;
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
voiceBtn.style.display = 'none';
return;
}
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.interimResults = false;
voiceBtn.addEventListener('click', () => {
recognition.start();
micIcon.classList.add('hidden');
micRecordingIcon.classList.remove('hidden');
});
recognition.onresult = (event: any) => {
const transcript = event.results[0][0].transcript;
searchBox.value = transcript;
searchBox.dispatchEvent(new Event('input'));
};
recognition.onend = () => {
micIcon.classList.remove('hidden');
micRecordingIcon.classList.add('hidden');
};
recognition.onerror = () => {
micIcon.classList.remove('hidden');
micRecordingIcon.classList.add('hidden');
};
}
// Sign out
function setupSignOut(): void {
const signOutBtn = document.getElementById('signOutBtn') as HTMLButtonElement;
signOutBtn.addEventListener('click', async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/signin.html';
} catch (err) {
console.error('Sign out failed:', err);
window.location.href = '/signin.html';
}
});
}
// Initialize on page load
async function init(): Promise<void> {
const isAuthenticated = await checkAuth();
if (!isAuthenticated) return;
setupSearch();
setupFilters();
setupVoiceSearch();
setupSignOut();
await fetchJobs();
}
function escapeHtml(str: string): string {
return str
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
// Start
init();
@@ -0,0 +1,129 @@
// job-cache.ts
var DB_NAME = "JobInfoCache";
var DB_VERSION = 2;
var JOBS_STORE = "jobs";
var META_STORE = "metadata";
var CACHE_TTL = 5 * 60 * 1000;
var PAGE_SIZE = 500;
class JobCache {
db = null;
async initialize() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(JOBS_STORE)) {
db.createObjectStore(JOBS_STORE, { keyPath: "Job_Number" });
}
if (!db.objectStoreNames.contains(META_STORE)) {
db.createObjectStore(META_STORE);
}
};
});
}
async getCachedJobs() {
if (!this.db)
throw new Error("Database not initialized");
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([JOBS_STORE], "readonly");
const store = transaction.objectStore(JOBS_STORE);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async isCacheValid() {
if (!this.db)
return false;
try {
const metadata = await this.getMetadata();
if (!metadata || !metadata.lastUpdated)
return false;
const age = Date.now() - metadata.lastUpdated;
return age < CACHE_TTL;
} catch {
return false;
}
}
async saveJobs(jobs) {
if (!this.db)
throw new Error("Database not initialized");
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
const jobsStore = transaction.objectStore(JOBS_STORE);
const metaStore = transaction.objectStore(META_STORE);
jobsStore.clear();
jobs.forEach((job) => jobsStore.add(job));
const metadata = {
lastUpdated: Date.now(),
version: DB_VERSION
};
metaStore.put(metadata, "main");
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async refreshJobs() {
try {
let page = 1;
let all = [];
let totalPages = 1;
while (page <= totalPages) {
const url = `/api/jobs?page=${page}&perPage=${PAGE_SIZE}&sort=-Job_Number`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch jobs (status ${response.status})`);
}
const data = await response.json();
const items = data.items || [];
const reportedTotalPages = data.totalPages || 1;
all = all.concat(items);
totalPages = Math.max(totalPages, reportedTotalPages);
page += 1;
}
await this.saveJobs(all);
return all;
} catch (err) {
console.error("Failed to refresh jobs cache:", err);
throw err;
}
}
async getMetadata() {
if (!this.db)
return null;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([META_STORE], "readonly");
const store = transaction.objectStore(META_STORE);
const request = store.get("main");
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
async clearCache() {
if (!this.db)
throw new Error("Database not initialized");
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([JOBS_STORE, META_STORE], "readwrite");
transaction.objectStore(JOBS_STORE).clear();
transaction.objectStore(META_STORE).clear();
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async getCacheAge() {
const metadata = await this.getMetadata();
if (!metadata || !metadata.lastUpdated)
return null;
return Date.now() - metadata.lastUpdated;
}
}
var jobCache = new JobCache;
export {
jobCache
};
@@ -0,0 +1,189 @@
// PERMANENT: IndexedDB cache for jobs data
// Provides lightning-fast job listing with zero-delay loading
//
// Strategy:
// - Display cached jobs instantly on page load (0ms delay)
// - Fetch fresh data in background after displaying cache
// - Cache TTL: 5 minutes (configurable)
// - Stores minimal fields for card display only
//
// Fields cached: Job_Number, Job_Name, Division, Active, Status, Estimator
//
// Usage:
// await jobCache.initialize();
// const jobs = await jobCache.getCachedJobs(); // Instant
// await jobCache.refreshJobs(); // Background refresh
export interface JobCard {
Job_Number: string;
Job_Name: string;
Division: string;
Active: string;
Status: string;
Estimator: string;
}
interface CacheMetadata {
lastUpdated: number;
version: number;
}
const DB_NAME = 'JobInfoCache';
const DB_VERSION = 2; // bump to invalidate old minimal-field cache
const JOBS_STORE = 'jobs';
const META_STORE = 'metadata';
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const PAGE_SIZE = 500; // match Mode1 bulk fetch
class JobCache {
private db: IDBDatabase | null = null;
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create jobs store with Job_Number as key
if (!db.objectStoreNames.contains(JOBS_STORE)) {
db.createObjectStore(JOBS_STORE, { keyPath: 'Job_Number' });
}
// Create metadata store
if (!db.objectStoreNames.contains(META_STORE)) {
db.createObjectStore(META_STORE);
}
};
});
}
async getCachedJobs(): Promise<JobCard[]> {
if (!this.db) throw new Error('Database not initialized');
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([JOBS_STORE], 'readonly');
const store = transaction.objectStore(JOBS_STORE);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async isCacheValid(): Promise<boolean> {
if (!this.db) return false;
try {
const metadata = await this.getMetadata();
if (!metadata || !metadata.lastUpdated) return false;
const age = Date.now() - metadata.lastUpdated;
return age < CACHE_TTL;
} catch {
return false;
}
}
async saveJobs(jobs: JobCard[]): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([JOBS_STORE, META_STORE], 'readwrite');
const jobsStore = transaction.objectStore(JOBS_STORE);
const metaStore = transaction.objectStore(META_STORE);
// Clear existing jobs
jobsStore.clear();
// Save new jobs
jobs.forEach(job => jobsStore.add(job));
// Update metadata
const metadata: CacheMetadata = {
lastUpdated: Date.now(),
version: DB_VERSION
};
metaStore.put(metadata, 'main');
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async refreshJobs(): Promise<JobCard[]> {
try {
// Fetch fresh data from backend with pagination to mirror Mode1 behavior
let page = 1;
let all: JobCard[] = [];
let totalPages = 1;
while (page <= totalPages) {
const url = `/api/jobs?page=${page}&perPage=${PAGE_SIZE}&sort=-Job_Number`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch jobs (status ${response.status})`);
}
const data = await response.json();
const items: JobCard[] = data.items || [];
const reportedTotalPages = data.totalPages || 1;
all = all.concat(items);
totalPages = Math.max(totalPages, reportedTotalPages);
page += 1;
}
// Save to cache
await this.saveJobs(all);
return all;
} catch (err) {
console.error('Failed to refresh jobs cache:', err);
throw err;
}
}
async getMetadata(): Promise<CacheMetadata | null> {
if (!this.db) return null;
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([META_STORE], 'readonly');
const store = transaction.objectStore(META_STORE);
const request = store.get('main');
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
async clearCache(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction([JOBS_STORE, META_STORE], 'readwrite');
transaction.objectStore(JOBS_STORE).clear();
transaction.objectStore(META_STORE).clear();
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async getCacheAge(): Promise<number | null> {
const metadata = await this.getMetadata();
if (!metadata || !metadata.lastUpdated) return null;
return Date.now() - metadata.lastUpdated;
}
}
export const jobCache = new JobCache();
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Manager</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="font-sans bg-gray-100 p-4">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-4 text-blue-600">Manager</h1>
<p class="text-gray-600">Manager view - Under construction</p>
<a href="/home.html" class="text-blue-600 underline mt-4 inline-block">← Back to Home</a>
</div>
</body>
</html>
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Navigator</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="font-sans bg-gray-100 p-4">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-4 text-blue-600">Navigator</h1>
<p class="text-gray-600">Navigator view - Under construction</p>
<a href="/home.html" class="text-blue-600 underline mt-4 inline-block">← Back to Home</a>
</div>
</body>
</html>
+21 -7
View File
@@ -48,14 +48,23 @@
console.log('[Auth] Graph token found:', !!graphToken);
// Send to backend to store
await fetch('/api/auth/login', {
const saveResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pbToken: authData.token, graphToken: graphToken || undefined })
});
// Redirect to app immediately
window.location.href = '/index.html';
if (!saveResponse.ok) {
throw new Error('Failed to save tokens to backend');
}
console.log('[Auth] Tokens saved to backend, redirecting to home');
// Clear PocketBase local storage to avoid conflicts
pb.authStore.clear();
// Redirect to home page
window.location.href = '/home.html';
} catch (err) {
document.getElementById('error').textContent = err.message || 'Login failed';
document.getElementById('error').classList.remove('hidden');
@@ -64,10 +73,15 @@
}
}
// Check if already logged in
if (pb.authStore.isValid) {
window.location.href = '/index.html';
}
// Check if already logged in via backend only
fetch('/api/auth/status')
.then(res => res.json())
.then(data => {
if (data.authenticated) {
window.location.href = '/home.html';
}
})
.catch(() => {});
</script>
</body>
</html>
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Viewer</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="font-sans bg-gray-100 p-4">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-4 text-blue-600">Viewer</h1>
<p class="text-gray-600">Viewer view - Under construction</p>
<a href="/home.html" class="text-blue-600 underline mt-4 inline-block">← Back to Home</a>
</div>
</body>
</html>
+5 -5
View File
@@ -1,7 +1,7 @@
{
"pbToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE4MDAxOTk4MTksImlkIjoiMWhiaWY5ZGJxbmc4Nmg5IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.CqJgLgGfLOk4Wk9ro16MwtVjSMYG2oY4s9GV_tLBvvc",
"pbTokenExpiry": 1769268619806,
"graphToken": "eyJ0eXAiOiJKV1QiLCJub25jZSI6IlBQamVpUk5STGVWbkpNWVJSNHlzVENhMEtlZzJTeEhwOTFLbll6NUxYcGciLCJhbGciOiJSUzI1NiIsIng1dCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSIsImtpZCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC8zZmQ5N2VhNy1iMTI0LTQxZjEtODU1Zi01MmQ4YWMzYjE2YzcvIiwiaWF0IjoxNzY4NjYzNTE5LCJuYmYiOjE3Njg2NjM1MTksImV4cCI6MTc2ODY2ODg3NSwiYWNjdCI6MCwiYWNyIjoiMSIsImFjcnMiOlsicDEiXSwiYWlvIjoiQVpRQWEvOGJBQUFBeTlyZWNsVzErcHErWE5hNXY0MkVrQUhaTEZwbGJ2SVJxZzFGWFprcWphbTR0eXk2dFdUTVFXZXNobGdJUFg2ZThMUXhoV2hrRE5MRDBuNE9mZW03a2QrcE1PM0ZPQTM5WmNFNFZjcWt1cWlTaUx1aFc0UDYyVkErOGV0ekZOOWFhcFdrMlR1T0ZQbVdpbDJEOEJ5QURaZmxFRTU5QkRtRVdsSTNvRkxZc3lvc2YwK1VtNGMyV2xsUTEwVjJQTFlLIiwiYW1yIjpbInB3ZCIsIm1mYSJdLCJhcHBfZGlzcGxheW5hbWUiOiJKb2JfSW5mb19TeW5jIiwiYXBwaWQiOiIzYzg0NmU3MS05NjA5LTQwZTEtYjQ1OC0wZWI4MDVlMjFiOWYiLCJhcHBpZGFjciI6IjEiLCJmYW1pbHlfbmFtZSI6IkV3aW5nIiwiZ2l2ZW5fbmFtZSI6IkFsZm9uc28iLCJpZHR5cCI6InVzZXIiLCJpcGFkZHIiOiIxNzIuNTkuNzIuMjQ4IiwibmFtZSI6IkFsZm9uc28gRXdpbmciLCJvaWQiOiIxYTZlOWQxMC0xMzhhLTQzZTQtYTA5ZS0xZjUwNDYzMTQ4YzkiLCJwbGF0ZiI6IjMiLCJwdWlkIjoiMTAwMzIwMDUxRkVCOEFDMCIsInJoIjoiMS5BVFVBcDM3WlB5U3g4VUdGWDFMWXJEc1d4d01BQUFBQUFBQUF3QUFBQUFBQUFBQmxBVWsxQUEuIiwic2NwIjoiQ2hhbm5lbC5SZWFkQmFzaWMuQWxsIENoYW5uZWxNZW1iZXIuUmVhZC5BbGwgQ2hhbm5lbE1lbWJlci5SZWFkV3JpdGUuQWxsIENoYW5uZWxNZXNzYWdlLkVkaXQgQ2hhbm5lbE1lc3NhZ2UuUmVhZC5BbGwgQ2hhbm5lbE1lc3NhZ2UuUmVhZFdyaXRlIENoYW5uZWxNZXNzYWdlLlNlbmQgQ2hhbm5lbFNldHRpbmdzLlJlYWQuQWxsIENoYW5uZWxTZXR0aW5ncy5SZWFkV3JpdGUuQWxsIENoYXRNZXNzYWdlLlJlYWQgQ2hhdE1lc3NhZ2UuU2VuZCBEaXJlY3RvcnkuQWNjZXNzQXNVc2VyLkFsbCBEaXJlY3RvcnkuUmVhZC5BbGwgRGlyZWN0b3J5LlJlYWRXcml0ZS5BbGwgZW1haWwgRmlsZXMuUmVhZFdyaXRlLkFsbCBHcm91cC5SZWFkLkFsbCBHcm91cC5SZWFkV3JpdGUuQWxsIEdyb3VwLUNvbnZlcnNhdGlvbi5SZWFkLkFsbCBHcm91cC1Db252ZXJzYXRpb24uUmVhZFdyaXRlLkFsbCBHcm91cE1lbWJlci5SZWFkLkFsbCBHcm91cE1lbWJlci5SZWFkV3JpdGUuQWxsIEdyb3VwU2V0dGluZ3MuUmVhZC5BbGwgR3JvdXBTZXR0aW5ncy5SZWFkV3JpdGUuQWxsIHByb2ZpbGUgU2l0ZXMuUmVhZFdyaXRlLkFsbCBUYXNrcy5SZWFkIFRhc2tzLlJlYWQuU2hhcmVkIFRhc2tzLlJlYWRXcml0ZSBUYXNrcy5SZWFkV3JpdGUuU2hhcmVkIFRlYW1zQWN0aXZpdHkuUmVhZCBUZWFtU2V0dGluZ3MuUmVhZC5BbGwgVGVhbVNldHRpbmdzLlJlYWRXcml0ZS5BbGwgVGVhbXNQb2xpY3lVc2VyQXNzaWduLlJlYWRXcml0ZS5BbGwgVXNlci5SZWFkIG9wZW5pZCIsInNpZCI6IjAwMTAzNDhhLTdkYWItNzBlZi1mODdmLWYyNGJhM2M5OTY2YyIsInNpZ25pbl9zdGF0ZSI6WyJrbXNpIl0sInN1YiI6IkFzVnNHelNzVE5hVm5SYk1sQ1p5VnlrWTJYejRIcktEaUVsdXlFWll2U3ciLCJ0ZW5hbnRfcmVnaW9uX3Njb3BlIjoiTkEiLCJ0aWQiOiIzZmQ5N2VhNy1iMTI0LTQxZjEtODU1Zi01MmQ4YWMzYjE2YzciLCJ1bmlxdWVfbmFtZSI6ImFld2luZ0BjYXJkb3phLmNvbnN0cnVjdGlvbiIsInVwbiI6ImFld2luZ0BjYXJkb3phLmNvbnN0cnVjdGlvbiIsInV0aSI6IjhadmQ0dkhBODA2Wmx1dnpJYkhaQUEiLCJ2ZXIiOiIxLjAiLCJ3aWRzIjpbImZlOTMwYmU3LTVlNjItNDdkYi05MWFmLTk4YzNhNDlhMzhiMSIsIjYyZTkwMzk0LTY5ZjUtNDIzNy05MTkwLTAxMjE3NzE0NWUxMCIsIjI5MjMyY2RmLTkzMjMtNDJmZC1hZGUyLTFkMDk3YWYzZTRkZSIsIjY5MDkxMjQ2LTIwZTgtNGE1Ni1hYTRkLTA2NjA3NWIyYTdhOCIsImI3OWZiZjRkLTNlZjktNDY4OS04MTQzLTc2YjE5NGU4NTUwOSJdLCJ4bXNfYWNkIjoxNzYzNDA1ODY0LCJ4bXNfYWN0X2ZjdCI6IjkgMyIsInhtc19mdGQiOiJvV0dWXzgxcHlJQksxWVlOYTJ6ck1sYlJsSENobmg2T1JtZVUwY1Y2d0ZBQmRYTjNaWE4wTXkxa2MyMXoiLCJ4bXNfaWRyZWwiOiIxIDQiLCJ4bXNfc3QiOnsic3ViIjoiQUhmNlFEcUV0ZHZEeXptWjB5TDJ0ZmRJLUltWVJxRlRJUWFMczdiSnZQYyJ9LCJ4bXNfc3ViX2ZjdCI6IjMgMiIsInhtc190Y2R0IjoxNTU4NTQ2MDA3LCJ4bXNfdG50X2ZjdCI6IjMgMiJ9.Dxx8iCgvn4kSLmXSdSKOwCB188jCQsLd-w5XBqNLuxlZXfzp8_DH_XVvZysGcffDrKkquSJ6oYOh4IjMy1ZLBl6lTz44Q3UoWiNZwZChsfNpyfk7lgI9ZnrLJizNFi9hpKOS28_pncT1ZRWJY6pIhs-coLO4_08CqynT3Vq_6LISwm1-rBOWr5A1_cP1An2Rnuu_3PEW8hmFHmMTvbXyIW9xxsExiNlTqr6w5h-dBMcOuzhuqRiDMhKplR8PaZrTdD8TizXAfaCepTyZPPa3rTKEn8jqiKmerit3mcx1H5RCFIgmxygdoTJVxXbEMx1SUfDb2t_M6r_yVjpNXfyqgQ",
"graphTokenExpiry": 1768667419806,
"lastRefresh": 1768663819806
"pbToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE4MDAyMTY5MjgsImlkIjoiMWhiaWY5ZGJxbmc4Nmg5IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.PCwm9Ac67h_n-lLv1BdzrqXDCsjfkEcWqTcgnwT5cGQ",
"pbTokenExpiry": 1769285728680,
"graphToken": "eyJ0eXAiOiJKV1QiLCJub25jZSI6InRSOVQyM1h4RXhKRHN1YXJuSkdmcmYzd2FDS094TF9xWUkwd2dLUjhMVTQiLCJhbGciOiJSUzI1NiIsIng1dCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSIsImtpZCI6IlBjWDk4R1g0MjBUMVg2c0JEa3poUW1xZ3dNVSJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC8zZmQ5N2VhNy1iMTI0LTQxZjEtODU1Zi01MmQ4YWMzYjE2YzcvIiwiaWF0IjoxNzY4Njc4Mjk1LCJuYmYiOjE3Njg2NzgyOTUsImV4cCI6MTc2ODY4MjIxMSwiYWNjdCI6MCwiYWNyIjoiMSIsImFjcnMiOlsicDEiXSwiYWlvIjoiQWRRQUsvOGJBQUFBMW1BM04rN1pMMFU5OURDcUtsZ2JiOE1pSlhYRXZOT0dMQ2FjVllmdWZMTVM3b2N1emlvdkVmd1dZQnRBV2VyK0U5bGZxUU9FZ1lVZnBOODAxK2srMXBqOW5NRHlqdHhuRWVkaWI5Q1hZbThTMmFLeVNmZWFaQTFFZVdOa2VqWkduN2ZRQSsxLzE5ckVKcWlqMlRRRWZBMm9RTTlDamwxSE5QbGpGQ05iTHZwcmtETFIrT29CVlM4VlBXZi8zTU00Q1pzQ2ZiZnNRb3FuSGpzUHBUTzdFMzk1Qk5UQVhveXoxL1lRTkFucVhmNHRGajVaMUZVWW9jY1hES0NubEc0NXBmTVMxSHMreE1ZWVdnT3h0MkhJeVE9PSIsImFtciI6WyJyc2EiLCJtZmEiXSwiYXBwX2Rpc3BsYXluYW1lIjoiSm9iX0luZm9fU3luYyIsImFwcGlkIjoiM2M4NDZlNzEtOTYwOS00MGUxLWI0NTgtMGViODA1ZTIxYjlmIiwiYXBwaWRhY3IiOiIxIiwiZGV2aWNlaWQiOiJkZjdmZmUyOC04NDQ2LTQ5NjUtOWNlMC1jNDJiNjIxZmFiYjciLCJmYW1pbHlfbmFtZSI6IkV3aW5nIiwiZ2l2ZW5fbmFtZSI6IkFsZm9uc28iLCJpZHR5cCI6InVzZXIiLCJpcGFkZHIiOiIxNDMuMTA1LjI0Ny4xNDYiLCJuYW1lIjoiQWxmb25zbyBFd2luZyIsIm9pZCI6IjFhNmU5ZDEwLTEzOGEtNDNlNC1hMDllLTFmNTA0NjMxNDhjOSIsInBsYXRmIjoiMyIsInB1aWQiOiIxMDAzMjAwNTFGRUI4QUMwIiwicmgiOiIxLkFUVUFwMzdaUHlTeDhVR0ZYMUxZckRzV3h3TUFBQUFBQUFBQXdBQUFBQUFBQUFCbEFVazFBQS4iLCJzY3AiOiJDaGFubmVsLlJlYWRCYXNpYy5BbGwgQ2hhbm5lbE1lbWJlci5SZWFkLkFsbCBDaGFubmVsTWVtYmVyLlJlYWRXcml0ZS5BbGwgQ2hhbm5lbE1lc3NhZ2UuRWRpdCBDaGFubmVsTWVzc2FnZS5SZWFkLkFsbCBDaGFubmVsTWVzc2FnZS5SZWFkV3JpdGUgQ2hhbm5lbE1lc3NhZ2UuU2VuZCBDaGFubmVsU2V0dGluZ3MuUmVhZC5BbGwgQ2hhbm5lbFNldHRpbmdzLlJlYWRXcml0ZS5BbGwgQ2hhdE1lc3NhZ2UuUmVhZCBDaGF0TWVzc2FnZS5TZW5kIERpcmVjdG9yeS5BY2Nlc3NBc1VzZXIuQWxsIERpcmVjdG9yeS5SZWFkLkFsbCBEaXJlY3RvcnkuUmVhZFdyaXRlLkFsbCBlbWFpbCBGaWxlcy5SZWFkV3JpdGUuQWxsIEdyb3VwLlJlYWQuQWxsIEdyb3VwLlJlYWRXcml0ZS5BbGwgR3JvdXAtQ29udmVyc2F0aW9uLlJlYWQuQWxsIEdyb3VwLUNvbnZlcnNhdGlvbi5SZWFkV3JpdGUuQWxsIEdyb3VwTWVtYmVyLlJlYWQuQWxsIEdyb3VwTWVtYmVyLlJlYWRXcml0ZS5BbGwgR3JvdXBTZXR0aW5ncy5SZWFkLkFsbCBHcm91cFNldHRpbmdzLlJlYWRXcml0ZS5BbGwgcHJvZmlsZSBTaXRlcy5SZWFkV3JpdGUuQWxsIFRhc2tzLlJlYWQgVGFza3MuUmVhZC5TaGFyZWQgVGFza3MuUmVhZFdyaXRlIFRhc2tzLlJlYWRXcml0ZS5TaGFyZWQgVGVhbXNBY3Rpdml0eS5SZWFkIFRlYW1TZXR0aW5ncy5SZWFkLkFsbCBUZWFtU2V0dGluZ3MuUmVhZFdyaXRlLkFsbCBUZWFtc1BvbGljeVVzZXJBc3NpZ24uUmVhZFdyaXRlLkFsbCBVc2VyLlJlYWQgb3BlbmlkIiwic2lkIjoiMDA4ZDJjYjktODU5MS1jMDdkLTVhMjQtYzk2MjhkYzc1ZDIzIiwic2lnbmluX3N0YXRlIjpbImttc2kiXSwic3ViIjoiQXNWc0d6U3NUTmFWblJiTWxDWnlWeWtZMlh6NEhyS0RpRWx1eUVaWXZTdyIsInRlbmFudF9yZWdpb25fc2NvcGUiOiJOQSIsInRpZCI6IjNmZDk3ZWE3LWIxMjQtNDFmMS04NTVmLTUyZDhhYzNiMTZjNyIsInVuaXF1ZV9uYW1lIjoiYWV3aW5nQGNhcmRvemEuY29uc3RydWN0aW9uIiwidXBuIjoiYWV3aW5nQGNhcmRvemEuY29uc3RydWN0aW9uIiwidXRpIjoiVDljb0FWV3ZXVTJTbTBGLUFqS3lBQSIsInZlciI6IjEuMCIsIndpZHMiOlsiZmU5MzBiZTctNWU2Mi00N2RiLTkxYWYtOThjM2E0OWEzOGIxIiwiNjJlOTAzOTQtNjlmNS00MjM3LTkxOTAtMDEyMTc3MTQ1ZTEwIiwiMjkyMzJjZGYtOTMyMy00MmZkLWFkZTItMWQwOTdhZjNlNGRlIiwiNjkwOTEyNDYtMjBlOC00YTU2LWFhNGQtMDY2MDc1YjJhN2E4IiwiYjc5ZmJmNGQtM2VmOS00Njg5LTgxNDMtNzZiMTk0ZTg1NTA5Il0sInhtc19hY2QiOjE3NjM0MDU4NjQsInhtc19hY3RfZmN0IjoiOSAzIiwieG1zX2Z0ZCI6IkYyUXJpNWN3ZEhLQzVuZzFrYjdsU1huX3BlWXFaR3JUT21KeDYyN21LZUVCZFhOM1pYTjBNeTFrYzIxeiIsInhtc19pZHJlbCI6IjggMSIsInhtc19zdCI6eyJzdWIiOiJBSGY2UURxRXRkdkR5em1aMHlMMnRmZEktSW1ZUnFGVElRYUxzN2JKdlBjIn0sInhtc19zdWJfZmN0IjoiMyA2IiwieG1zX3RjZHQiOjE1NTg1NDYwMDcsInhtc190bnRfZmN0IjoiNiAzIn0.UqBTFe_GkrUqp9oHgjO93KtXckuDH91RQ03fV8ZzgUit4Vre1EXctVC-Zm5j5kfSM_6pb99VKasBPAolbIa4U-_J1yGidc81sghJj3Haz048v9_074Ky4Qzl0czNQ7OYTn2N1xKuYeBEzXCMt6Cc8gpGJv372seP0CVTs6ZxFTAfRXw2PvHsrJdiEJQMpMoSLBRC_bF9s223o6PiN3v1XgGbAqwP3WI-0GhtyK5Tnul0WJIH5i9G2sA3DlDAmxFm51RwxZtaeqhYPGNEkHrUQqBkNHeAiZ_VqvmGHyuj-D7E-XHOGkF8bQPCHWVXeLdZJNkOxxBEgqYNBpp2QWBWVg",
"graphTokenExpiry": 1768684528680,
"lastRefresh": 1768680928680
}
File diff suppressed because it is too large Load Diff