Files
Job-Info/SVELTE_PATTERNS_REFERENCE.md
T
aewing 42ff3e8f33
Build & Deploy / Test & Lint (push) Has been cancelled
Build & Deploy / Security Scan (push) Has been cancelled
Code Quality / Code Quality Checks (push) Has been cancelled
Code Quality / Dependency Vulnerabilities (push) Has been cancelled
Code Quality / Performance Testing (push) Has been cancelled
Build & Deploy / Build Docker Images (push) Has been cancelled
Build & Deploy / Deploy to Kubernetes (push) Has been cancelled
Add Mode6Test: copy of Mode5Test running on port 3000
2026-01-20 13:43:29 +00:00

499 lines
14 KiB
Markdown

/**
* REFERENCE TOOL: Svelte + SvelteKit Best Practices & Patterns
*
* Every Svelte file follows these patterns. No exceptions.
*/
// ============================================================================
// PATTERN 1: Svelte Store Pattern
// ============================================================================
/**
* STORE: authStore
* PURPOSE: Centralized auth state (user, tokens, login status)
* UPDATES: Called only through explicit update functions
* SUBSCRIBERS: Any component needing auth state
*
* Files reference this store: components that check auth, routes that require auth
*/
// File: src/stores/auth.ts
import { writable, derived } from 'svelte/store';
/**
* USER STORE
* Holds current user info from Microsoft
*/
export const user = writable<User | null>(null);
/**
* ACCESS TOKEN STORE
* Holds current access token (short-lived, 1 hour)
* RULE: Cleared when token expires
* RULE: Never persisted to localStorage
*/
export const accessToken = writable<string | null>(null);
/**
* TOKEN EXPIRY STORE
* When does current token expire (timestamp in ms)
*/
export const tokenExpiry = writable<number | null>(null);
/**
* DERIVED: Is authenticated?
* Returns true if user exists and token is not expired
*/
export const isAuthenticated = derived(
[user, tokenExpiry],
([$user, $expiry]) => {
return !!$user && $expiry && Date.now() < $expiry;
}
);
/**
* UPDATE FUNCTION: Set user after login
*
* CALLED BY: OAuth callback handler
* UPDATES: user, accessToken, tokenExpiry
* SIDE EFFECTS: Stores token in sessionStorage, sets up refresh timer
*/
export async function setAuthUser(
userInfo: User,
token: string,
expiresIn: number
): Promise<void> {
// Update stores
user.set(userInfo);
accessToken.set(token);
tokenExpiry.set(Date.now() + expiresIn * 1000);
// Store in sessionStorage (safe, lost on browser close)
sessionStorage.setItem('ms_access_token', token);
sessionStorage.setItem('ms_token_expiry', (Date.now() + expiresIn * 1000).toString());
// Set up automatic refresh timer (5 minutes before expiry)
const refreshIn = (expiresIn * 1000) - (5 * 60 * 1000);
setTimeout(() => {
refreshAccessToken();
}, refreshIn);
console.log('Auth user set:', userInfo.displayName);
}
/**
* UPDATE FUNCTION: Refresh token via backend
*
* CALLED BY: Automatic timer (5 min before expiry), before API calls
* SIDE EFFECTS: Updates accessToken, tokenExpiry, sessionStorage
*/
export async function refreshAccessToken(): Promise<void> {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Authorization': `Bearer ${get(user)?.id}` },
});
if (!response.ok) {
// Token refresh failed
clearAuth(); // Force re-login
throw new Error('Token refresh failed');
}
const { accessToken: newToken, expiresIn } = await response.json();
// Update stores
accessToken.set(newToken);
tokenExpiry.set(Date.now() + expiresIn * 1000);
sessionStorage.setItem('ms_access_token', newToken);
sessionStorage.setItem('ms_token_expiry', (Date.now() + expiresIn * 1000).toString());
// Reset refresh timer
setTimeout(() => {
refreshAccessToken();
}, (expiresIn * 1000) - (5 * 60 * 1000));
console.log('Access token refreshed');
} catch (error) {
console.error('Token refresh error:', error);
clearAuth();
}
}
/**
* UPDATE FUNCTION: Logout
*
* CALLED BY: Logout button, auth failures
* SIDE EFFECTS: Clears all auth stores, sessionStorage, redirects
*/
export async function clearAuth(): Promise<void> {
user.set(null);
accessToken.set(null);
tokenExpiry.set(null);
sessionStorage.removeItem('ms_access_token');
sessionStorage.removeItem('ms_token_expiry');
// Call backend logout (optional, clears refresh token from PocketBase)
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (e) {
console.error('Logout API error:', e);
}
console.log('Auth cleared');
}
// ============================================================================
// PATTERN 2: Svelte Component with Auth Requirement
// ============================================================================
/**
* COMPONENT: JobList.svelte
* PURPOSE: Display list of jobs from Microsoft Graph
* REQUIRES: User to be authenticated
*
* Reactive: $isAuthenticated, $jobs, searchQuery
* Events: None (pure presentation)
* API calls: Loads jobs via GraphService
*/
// File: src/components/JobList.svelte
<script lang="ts">
// Imports - organize by type
import { onMount } from 'svelte';
import { isAuthenticated, accessToken } from '../stores/auth';
import { jobs, jobsLoading, searchJobs } from '../stores/jobs';
import JobCard from './JobCard.svelte';
import SearchBar from './SearchBar.svelte';
// Props (none in this case)
export let jobCategory: string = 'all';
// Reactive state
let searchQuery = '';
let filteredJobs: Job[] = [];
// Lifecycle: Load jobs on component mount
onMount(async () => {
if (!$isAuthenticated) {
return; // Don't load if not authenticated
}
jobsLoading.set(true);
try {
await searchJobs(jobCategory);
} catch (error) {
console.error('Failed to load jobs:', error);
} finally {
jobsLoading.set(false);
}
});
// Reactive: Filter jobs based on search query
$: filteredJobs = $jobs.filter(job =>
job.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
job.company.toLowerCase().includes(searchQuery.toLowerCase())
);
// Event handler: Search input
function handleSearch(e: CustomEvent<string>) {
searchQuery = e.detail;
}
</script>
{#if !$isAuthenticated}
<div class="p-4 bg-yellow-50 text-yellow-800 rounded">
Please sign in to view jobs
</div>
{:else if $jobsLoading}
<div class="p-4">Loading jobs...</div>
{:else if filteredJobs.length === 0}
<div class="p-4">No jobs found matching your search</div>
{:else}
<div class="space-y-4">
<SearchBar on:search={handleSearch} />
<div class="grid grid-cols-1 gap-4">
{#each filteredJobs as job (job.id)}
<JobCard {job} />
{/each}
</div>
</div>
{/if}
<style>
/* Use TailwindCSS classes only, no custom CSS needed here */
</style>
// ============================================================================
// PATTERN 3: API Service Layer
// ============================================================================
/**
* SERVICE: graphService
* PURPOSE: Handle all Microsoft Graph API calls with proper headers
* DEPENDENCIES: accessToken store, error handling
*
* Exports: Functions for each Graph API operation
* Error handling: Throws errors that UI catches
*/
// File: src/services/graphService.ts
import { get } from 'svelte/store';
import { accessToken, refreshAccessToken, clearAuth } from '../stores/auth';
/**
* Helper: Ensure token is valid before API call
*
* Checks if token expired, refreshes if needed
* Throws if no token available
*/
async function ensureValidToken(): Promise<string> {
const token = get(accessToken);
if (!token) {
throw new Error('No access token available - not authenticated');
}
// Check if token might be expired, refresh proactively
const expiry = Number(sessionStorage.getItem('ms_token_expiry') || 0);
if (expiry - Date.now() < 5 * 60 * 1000) {
// Token expires in less than 5 minutes, refresh now
await refreshAccessToken();
}
return get(accessToken) || token;
}
/**
* API CALL: Get list of files in OneDrive
*
* Endpoint: GET https://graph.microsoft.com/v1.0/me/drive/root/children
* Scopes required: Files.Read.All
* Caching: Results cached in IndexedDB via jobsStore
*
* Errors:
* - 401: Token invalid, user redirected to login
* - 403: Insufficient permissions
* - 404: Folder not found
* - 429: Rate limited, should retry with backoff
*/
export async function listFiles(folderId: string = 'root'): Promise<File[]> {
const token = await ensureValidToken();
const endpoint = folderId === 'root'
? 'https://graph.microsoft.com/v1.0/me/drive/root/children'
: `https://graph.microsoft.com/v1.0/me/drive/items/${folderId}/children`;
try {
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
// Token invalid, clear auth and redirect
clearAuth();
throw new Error('Session expired - please log in again');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Graph API error ${response.status}: ${error.message}`);
}
const data = await response.json();
return data.value || [];
} catch (error) {
console.error('Graph API error:', error);
throw error;
}
}
// ============================================================================
// PATTERN 4: Data Store (Derived from API)
// ============================================================================
/**
* STORE: jobs
* PURPOSE: Cached list of jobs loaded from device storage (IndexedDB)
* UPDATES: Called by services after fetching from Graph API
* PERSISTENCE: IndexedDB database
*
* STRUCTURE:
* - jobs: Job[] - Current list of jobs
* - jobsLoading: boolean - Loading state
* - jobsError: string | null - Last error if any
*/
// File: src/stores/jobs.ts
import { writable } from 'svelte/store';
import { loadJobsFromDB, saveJobsToDBt } from '../services/storageService';
export const jobs = writable<Job[]>([]);
export const jobsLoading = writable(false);
export const jobsError = writable<string | null>(null);
/**
* Load jobs from device storage
*
* Called on app init
* Populates jobs store from IndexedDB
*/
export async function loadJobs(): Promise<void> {
jobsLoading.set(true);
try {
const cachedJobs = await loadJobsFromDB();
jobs.set(cachedJobs);
jobsError.set(null);
} catch (error) {
console.error('Failed to load jobs:', error);
jobsError.set((error as Error).message);
} finally {
jobsLoading.set(false);
}
}
/**
* Search jobs locally (no API call needed)
*
* All job data is already in device storage
* This filters locally for instant results
*/
export async function searchJobs(query: string): Promise<Job[]> {
// Get current jobs
const allJobs = get(jobs);
// Filter based on query
const results = allJobs.filter(job =>
job.title.toLowerCase().includes(query.toLowerCase()) ||
job.company.toLowerCase().includes(query.toLowerCase()) ||
job.description.toLowerCase().includes(query.toLowerCase())
);
return results;
}
// ============================================================================
// PATTERN 5: Layout Component (Root)
// ============================================================================
/**
* COMPONENT: +layout.svelte
* PURPOSE: Root layout for all pages
* FEATURES: Navigation, auth check, error handling
*
* Lifecycle: Checks auth on mount, refreshes token
*/
// File: src/routes/+layout.svelte
<script lang="ts">
import { onMount } from 'svelte';
import { isAuthenticated, user, clearAuth } from '../stores/auth';
import { loadJobs } from '../stores/jobs';
import Navigation from '../components/Navigation.svelte';
import ErrorBoundary from '../components/ErrorBoundary.svelte';
let error: Error | null = null;
onMount(async () => {
// Load cached jobs on app startup
try {
await loadJobs();
} catch (e) {
console.error('Failed to load initial jobs:', e);
error = e as Error;
}
});
</script>
<div class="min-h-screen bg-gray-50">
<Navigation />
{#if error}
<ErrorBoundary {error} />
{/if}
<main class="container mx-auto p-4">
<slot />
</main>
</div>
<style>
/* Global styles as needed */
</style>
// ============================================================================
// PATTERN 6: Route Page with Auth Guard
// ============================================================================
/**
* PAGE: +page.svelte (Home)
* PURPOSE: Job listing homepage
* AUTH: Redirects to login if not authenticated
*/
// File: src/routes/+page.svelte
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { isAuthenticated } from '../stores/auth';
import JobList from '../components/JobList.svelte';
onMount(() => {
if (!$isAuthenticated) {
goto('/signin');
}
});
</script>
{#if $isAuthenticated}
<h1 class="text-3xl font-bold mb-6">Job Listings</h1>
<JobList />
{/if}
// File: src/routes/signin/+page.svelte (OAuth login page)
<script lang="ts">
import { goto } from '$app/navigation';
import { isAuthenticated } from '../../stores/auth';
onMount(() => {
if ($isAuthenticated) {
goto('/'); // Already logged in
}
});
function handleLogin() {
// Initiate OAuth flow
initiateOAuthFlow();
}
</script>
<div class="flex items-center justify-center min-h-screen">
<button
on:click={handleLogin}
class="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Sign in with Microsoft
</button>
</div>
// ============================================================================
// SUMMARY
// ============================================================================
/**
* SVELTE PATTERNS - GOLDEN RULES:
*
* 1. STORES: Centralize state, update via explicit functions
* 2. COMPONENTS: Keep reactive, let stores handle state
* 3. SERVICES: Handle API calls with error handling, never expose tokens
* 4. LIFECYCLE: Use onMount for async operations
* 5. DERIVED: Use derived stores for computed state (isAuthenticated)
* 6. REACTIVITY: Use $store syntax to subscribe in templates
* 7. PROPS: Always document and type check
* 8. ERROR HANDLING: Catch all async operations, display to user
* 9. PERSISTENCE: Use IndexedDB for device storage, sessionStorage for tokens
* 10. TESTING: Every component independently testable
*/