From bc763abc1b9b19fb99819f830cc874f111268a1f Mon Sep 17 00:00:00 2001 From: iajibose Date: Thu, 25 Jun 2026 17:38:59 +0100 Subject: [PATCH 1/2] feat: implement Vertical Pod Autoscaling for FAQ System --- k8s/faq-system-deployment.yaml | 50 +++++++++++++++++++ k8s/faq-system-vpa.test.ts | 89 ++++++++++++++++++++++++++++++++++ k8s/faq-system-vpa.yaml | 28 +++++++++++ 3 files changed, 167 insertions(+) create mode 100644 k8s/faq-system-deployment.yaml create mode 100644 k8s/faq-system-vpa.test.ts create mode 100644 k8s/faq-system-vpa.yaml diff --git a/k8s/faq-system-deployment.yaml b/k8s/faq-system-deployment.yaml new file mode 100644 index 00000000..8405f686 --- /dev/null +++ b/k8s/faq-system-deployment.yaml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: teachlink-faq-system + namespace: production + labels: + app: teachlink + component: faq-system +spec: + replicas: 2 + selector: + matchLabels: + app: teachlink + component: faq-system + template: + metadata: + labels: + app: teachlink + component: faq-system + spec: + containers: + - name: faq-system + image: rinafcode/teachlink-faq-system:latest + imagePullPolicy: Always + ports: + - containerPort: 3000 + resources: + requests: + cpu: 150m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + env: + - name: NODE_ENV + value: 'production' + - name: PORT + value: '3000' + readinessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 15 + livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 30 diff --git a/k8s/faq-system-vpa.test.ts b/k8s/faq-system-vpa.test.ts new file mode 100644 index 00000000..868532c1 --- /dev/null +++ b/k8s/faq-system-vpa.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const deploymentManifest = readFileSync( + join(process.cwd(), 'k8s/faq-system-deployment.yaml'), + 'utf8', +); +const vpaManifest = readFileSync(join(process.cwd(), 'k8s/faq-system-vpa.yaml'), 'utf8'); + +describe('faq-system deployment manifest', () => { + it('declares deployment metadata and namespace correctly', () => { + expect(deploymentManifest).toContain('name: teachlink-faq-system'); + expect(deploymentManifest).toContain('namespace: production'); + expect(deploymentManifest).toContain('component: faq-system'); + }); + + it('configures container image and port', () => { + expect(deploymentManifest).toContain('image: rinafcode/teachlink-faq-system:latest'); + expect(deploymentManifest).toContain('imagePullPolicy: Always'); + expect(deploymentManifest).toContain('containerPort: 3000'); + }); + + it('sets production environment variables', () => { + expect(deploymentManifest).toContain('name: NODE_ENV'); + expect(deploymentManifest).toContain("value: 'production'"); + expect(deploymentManifest).toContain('name: PORT'); + expect(deploymentManifest).toContain("value: '3000'"); + }); + + it('defines initial resource requests and limits compatible with VPA', () => { + expect(deploymentManifest).toContain('cpu: 150m'); + expect(deploymentManifest).toContain('memory: 256Mi'); + expect(deploymentManifest).toContain('cpu: 500m'); + expect(deploymentManifest).toContain('memory: 512Mi'); + }); + + it('includes readiness and liveness probes', () => { + expect(deploymentManifest).toContain('readinessProbe'); + expect(deploymentManifest).toContain('livenessProbe'); + expect(deploymentManifest).toContain('path: /api/health'); + }); +}); + +describe('faq-system VPA manifest', () => { + it('uses the VPA API version and kind', () => { + expect(vpaManifest).toContain('apiVersion: autoscaling.k8s.io/v1'); + expect(vpaManifest).toContain('kind: VerticalPodAutoscaler'); + }); + + it('declares VPA name and namespace', () => { + expect(vpaManifest).toContain('name: teachlink-faq-system-vpa'); + expect(vpaManifest).toContain('namespace: production'); + expect(vpaManifest).toContain('component: faq-system'); + }); + + it('targets the faq-system deployment', () => { + expect(vpaManifest).toContain('apiVersion: apps/v1'); + expect(vpaManifest).toContain('kind: Deployment'); + expect(vpaManifest).toContain('name: teachlink-faq-system'); + }); + + it('sets update mode to Auto', () => { + expect(vpaManifest).toContain('updateMode: "Auto"'); + }); + + it('specifies container resource policy for faq-system container', () => { + expect(vpaManifest).toContain('containerName: faq-system'); + expect(vpaManifest).toContain('controlledResources'); + expect(vpaManifest).toContain('- cpu'); + expect(vpaManifest).toContain('- memory'); + }); + + it('enforces minimum resource allowances', () => { + expect(vpaManifest).toContain('minAllowed'); + expect(vpaManifest).toContain('cpu: 100m'); + expect(vpaManifest).toContain('memory: 128Mi'); + }); + + it('enforces maximum resource allowances', () => { + expect(vpaManifest).toContain('maxAllowed'); + expect(vpaManifest).toContain('cpu: 2000m'); + expect(vpaManifest).toContain('memory: 2Gi'); + }); + + it('controls both requests and limits', () => { + expect(vpaManifest).toContain('controlledValues: RequestsAndLimits'); + }); +}); diff --git a/k8s/faq-system-vpa.yaml b/k8s/faq-system-vpa.yaml new file mode 100644 index 00000000..b3d5da80 --- /dev/null +++ b/k8s/faq-system-vpa.yaml @@ -0,0 +1,28 @@ +apiVersion: autoscaling.k8s.io/v1 +kind: VerticalPodAutoscaler +metadata: + name: teachlink-faq-system-vpa + namespace: production + labels: + app: teachlink + component: faq-system +spec: + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: teachlink-faq-system + updatePolicy: + updateMode: "Auto" + resourcePolicy: + containerPolicies: + - containerName: faq-system + minAllowed: + cpu: 100m + memory: 128Mi + maxAllowed: + cpu: 2000m + memory: 2Gi + controlledResources: + - cpu + - memory + controlledValues: RequestsAndLimits From 50555da204ab4e574756ff217a9e02b9c5cd7133 Mon Sep 17 00:00:00 2001 From: iajibose Date: Fri, 26 Jun 2026 16:47:48 +0100 Subject: [PATCH 2/2] fix: apply prettier formatting to resolve lint failures Co-Authored-By: Claude Sonnet 4.6 --- .../assignments/SignatureCapture.tsx | 334 +++++++++--------- .../editor/MaterialEditorWrapper.tsx | 174 +++++---- src/components/feedback/FeedbackWidget.tsx | 6 +- .../privacy/PrivacyReleaseNotes.tsx | 25 +- src/components/ui/ModalFeedbackLoop.tsx | 6 +- src/utils/emailFeedback.ts | 5 +- 6 files changed, 251 insertions(+), 299 deletions(-) diff --git a/src/components/assignments/SignatureCapture.tsx b/src/components/assignments/SignatureCapture.tsx index 492117e9..963efc35 100644 --- a/src/components/assignments/SignatureCapture.tsx +++ b/src/components/assignments/SignatureCapture.tsx @@ -9,14 +9,7 @@ * a typed-name fallback when canvas is unavailable, and accessibility wiring. */ -import { - useCallback, - useEffect, - useImperativeHandle, - useRef, - useState, - forwardRef, -} from 'react'; +import { useCallback, useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react'; export interface SignatureCaptureHandle { clear: () => void; @@ -33,184 +26,173 @@ export interface SignatureCaptureProps { ariaLabel?: string; } -export const SignatureCapture = forwardRef< - SignatureCaptureHandle, - SignatureCaptureProps ->(function SignatureCapture( - { - width = 480, - height = 160, - onChange, - requireAccept = true, - ariaLabel = 'Signature canvas', - }, - ref, -) { - const canvasRef = useRef(null); - const drawingRef = useRef(false); - const [hasStrokes, setHasStrokes] = useState(false); - const [accepted, setAccepted] = useState(!requireAccept); - const [fallbackName, setFallbackName] = useState(''); - const [hasCanvas, setHasCanvas] = useState(true); - - useEffect(() => { - if (typeof HTMLCanvasElement === 'undefined') { - setHasCanvas(false); - } - }, []); - - const ctxRef = useRef(null); - - const getCtx = useCallback(() => { - const c = canvasRef.current; - if (!c) return null; - if (!ctxRef.current) { - const ctx = c.getContext('2d'); - if (ctx) { - ctx.lineWidth = 2; - ctx.lineCap = 'round'; - ctx.strokeStyle = '#111'; - ctxRef.current = ctx; +export const SignatureCapture = forwardRef( + function SignatureCapture( + { width = 480, height = 160, onChange, requireAccept = true, ariaLabel = 'Signature canvas' }, + ref, + ) { + const canvasRef = useRef(null); + const drawingRef = useRef(false); + const [hasStrokes, setHasStrokes] = useState(false); + const [accepted, setAccepted] = useState(!requireAccept); + const [fallbackName, setFallbackName] = useState(''); + const [hasCanvas, setHasCanvas] = useState(true); + + useEffect(() => { + if (typeof HTMLCanvasElement === 'undefined') { + setHasCanvas(false); + } + }, []); + + const ctxRef = useRef(null); + + const getCtx = useCallback(() => { + const c = canvasRef.current; + if (!c) return null; + if (!ctxRef.current) { + const ctx = c.getContext('2d'); + if (ctx) { + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.strokeStyle = '#111'; + ctxRef.current = ctx; + } + } + return ctxRef.current; + }, []); + + const point = (e: React.MouseEvent | React.TouchEvent): { x: number; y: number } | null => { + const c = canvasRef.current; + if (!c) return null; + const rect = c.getBoundingClientRect(); + if ('touches' in e) { + const t = e.touches[0] ?? e.changedTouches[0]; + return { + x: (t.clientX - rect.left) * (c.width / rect.width), + y: (t.clientY - rect.top) * (c.height / rect.height), + }; } - } - return ctxRef.current; - }, []); - - const point = ( - e: React.MouseEvent | React.TouchEvent, - ): { x: number; y: number } | null => { - const c = canvasRef.current; - if (!c) return null; - const rect = c.getBoundingClientRect(); - if ('touches' in e) { - const t = e.touches[0] ?? e.changedTouches[0]; return { - x: (t.clientX - rect.left) * (c.width / rect.width), - y: (t.clientY - rect.top) * (c.height / rect.height), + x: (e.clientX - rect.left) * (c.width / rect.width), + y: (e.clientY - rect.top) * (c.height / rect.height), }; - } - return { - x: (e.clientX - rect.left) * (c.width / rect.width), - y: (e.clientY - rect.top) * (c.height / rect.height), }; - }; - - const begin = useCallback((e: React.MouseEvent | React.TouchEvent) => { - const p = point(e); - const ctx = getCtx(); - if (!p || !ctx) return; - drawingRef.current = true; - ctx.beginPath(); - ctx.moveTo(p.x, p.y); - }, [getCtx]); - - const move = useCallback((e: React.MouseEvent | React.TouchEvent) => { - if (!drawingRef.current) return; - const p = point(e); - const ctx = getCtx(); - if (!p || !ctx) return; - ctx.lineTo(p.x, p.y); - ctx.stroke(); - }, [getCtx]); - - const end = useCallback(() => { - if (!drawingRef.current) return; - drawingRef.current = false; - setHasStrokes(true); - const url = canvasRef.current?.toDataURL('image/png') ?? null; - onChange?.(url); - }, [onChange]); - - const clear = useCallback(() => { - const c = canvasRef.current; - const ctx = getCtx(); - if (c && ctx) { - ctx.clearRect(0, 0, c.width, c.height); - } - setHasStrokes(false); - setAccepted(!requireAccept); - onChange?.(null); - }, [getCtx, onChange, requireAccept]); - const toDataURL = useCallback(() => { - return canvasRef.current?.toDataURL('image/png') ?? null; - }, []); + const begin = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + const p = point(e); + const ctx = getCtx(); + if (!p || !ctx) return; + drawingRef.current = true; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + }, + [getCtx], + ); - useImperativeHandle( - ref, - () => ({ clear, toDataURL }), - [clear, toDataURL], - ); + const move = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + if (!drawingRef.current) return; + const p = point(e); + const ctx = getCtx(); + if (!p || !ctx) return; + ctx.lineTo(p.x, p.y); + ctx.stroke(); + }, + [getCtx], + ); - const handleAccept = useCallback(() => { - setAccepted(true); - onChange?.(canvasRef.current?.toDataURL('image/png') ?? null); - }, [onChange]); + const end = useCallback(() => { + if (!drawingRef.current) return; + drawingRef.current = false; + setHasStrokes(true); + const url = canvasRef.current?.toDataURL('image/png') ?? null; + onChange?.(url); + }, [onChange]); + + const clear = useCallback(() => { + const c = canvasRef.current; + const ctx = getCtx(); + if (c && ctx) { + ctx.clearRect(0, 0, c.width, c.height); + } + setHasStrokes(false); + setAccepted(!requireAccept); + onChange?.(null); + }, [getCtx, onChange, requireAccept]); + + const toDataURL = useCallback(() => { + return canvasRef.current?.toDataURL('image/png') ?? null; + }, []); + + useImperativeHandle(ref, () => ({ clear, toDataURL }), [clear, toDataURL]); + + const handleAccept = useCallback(() => { + setAccepted(true); + onChange?.(canvasRef.current?.toDataURL('image/png') ?? null); + }, [onChange]); + + if (!hasCanvas) { + return ( +
+ +
+ ); + } - if (!hasCanvas) { return ( -
- -
- ); - } - - return ( -
- -
- - {requireAccept ? ( - - ) : null} - {!hasStrokes ? ( - - Sign in the box above. - - ) : null} + {requireAccept ? ( + + ) : null} + {!hasStrokes ? ( + + Sign in the box above. + + ) : null} +
- - ); -}); + ); + }, +); export default SignatureCapture; diff --git a/src/components/editor/MaterialEditorWrapper.tsx b/src/components/editor/MaterialEditorWrapper.tsx index 9b75af4f..e57fe822 100644 --- a/src/components/editor/MaterialEditorWrapper.tsx +++ b/src/components/editor/MaterialEditorWrapper.tsx @@ -24,26 +24,23 @@ export interface MaterialEditorWrapperProps { type Ripple = { x: number; y: number; size: number; k: string }; -export const MaterialEditorWrapper = forwardRef< - HTMLDivElement, - MaterialEditorWrapperProps ->(function MaterialEditorWrapper( - { - title, - children, - onSubmit, - submitLabel = 'Publish', - cancelLabel = 'Cancel', - onCancel, - className = '', - }, - ref, -) { - const [ripples, setRipples] = useState([]); - const [submitting, setSubmitting] = useState(false); +export const MaterialEditorWrapper = forwardRef( + function MaterialEditorWrapper( + { + title, + children, + onSubmit, + submitLabel = 'Publish', + cancelLabel = 'Cancel', + onCancel, + className = '', + }, + ref, + ) { + const [ripples, setRipples] = useState([]); + const [submitting, setSubmitting] = useState(false); - const spawnRipple = useCallback( - (e: React.MouseEvent) => { + const spawnRipple = useCallback((e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const size = Math.max(rect.width, rect.height) * 1.6; const k = `${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -57,77 +54,76 @@ export const MaterialEditorWrapper = forwardRef< window.setTimeout(() => { setRipples((r) => r.filter((rp) => rp.k !== k)); }, 600); - }, - [], - ); + }, []); - const handleSubmit = useCallback( - async (e: React.MouseEvent) => { - spawnRipple(e); - if (!onSubmit) return; - setSubmitting(true); - try { - await onSubmit(); - } finally { - setSubmitting(false); - } - }, - [onSubmit, spawnRipple], - ); + const handleSubmit = useCallback( + async (e: React.MouseEvent) => { + spawnRipple(e); + if (!onSubmit) return; + setSubmitting(true); + try { + await onSubmit(); + } finally { + setSubmitting(false); + } + }, + [onSubmit, spawnRipple], + ); - return ( -
-
-

{title}

-
-
{children}
-
- {onCancel ? ( - - ) : null} - {onSubmit ? ( - - ) : null} -
-
- ); -}); + return ( +
+
+

{title}

+
+
{children}
+
+ {onCancel ? ( + + ) : null} + {onSubmit ? ( + + ) : null} +
+
+ ); + }, +); export default MaterialEditorWrapper; diff --git a/src/components/feedback/FeedbackWidget.tsx b/src/components/feedback/FeedbackWidget.tsx index a3f426ce..4ef3ff87 100644 --- a/src/components/feedback/FeedbackWidget.tsx +++ b/src/components/feedback/FeedbackWidget.tsx @@ -104,11 +104,7 @@ export default function FeedbackWidget({ {error}

) : null} - diff --git a/src/components/privacy/PrivacyReleaseNotes.tsx b/src/components/privacy/PrivacyReleaseNotes.tsx index 98001017..625abb30 100644 --- a/src/components/privacy/PrivacyReleaseNotes.tsx +++ b/src/components/privacy/PrivacyReleaseNotes.tsx @@ -2,12 +2,7 @@ import { useMemo, useState } from 'react'; -export type ReleaseNoteKind = - | 'added' - | 'changed' - | 'fixed' - | 'security' - | 'deprecated'; +export type ReleaseNoteKind = 'added' | 'changed' | 'fixed' | 'security' | 'deprecated'; export interface ReleaseNote { kind: ReleaseNoteKind; @@ -47,10 +42,7 @@ function slug(version: string): string { return version.replace(/[^a-z0-9]+/gi, '-').toLowerCase(); } -export default function PrivacyReleaseNotes({ - notes, - initialVersion, -}: PrivacyReleaseNotesProps) { +export default function PrivacyReleaseNotes({ notes, initialVersion }: PrivacyReleaseNotesProps) { const sorted = useMemo( () => [...notes].sort((a, b) => b.effectiveAt.localeCompare(a.effectiveAt)), [notes], @@ -80,21 +72,14 @@ export default function PrivacyReleaseNotes({ type="button" aria-expanded={open} aria-controls={`rel-${slug(release.version)}`} - onClick={() => - setOpenVersion(open ? null : release.version) - } + onClick={() => setOpenVersion(open ? null : release.version)} className="w-full text-left" > v{release.version}{' '} - - — effective {release.effectiveAt} - + — effective {release.effectiveAt} {open ? ( -
    +
      {release.notes.map((n, idx) => (
    • { children: React.ReactNode; /** Arbitrary context passed back via onFeedback; e.g. modal id, form values. */ modalData: T; - onFeedback: (entry: { - rating: Rating; - comment?: string; - sourceData: T; - }) => Promise | void; + onFeedback: (entry: { rating: Rating; comment?: string; sourceData: T }) => Promise | void; onClose: () => void; size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; } diff --git a/src/utils/emailFeedback.ts b/src/utils/emailFeedback.ts index d9a11956..9d7140fe 100644 --- a/src/utils/emailFeedback.ts +++ b/src/utils/emailFeedback.ts @@ -5,10 +5,7 @@ * Persistence is delegated to a caller-supplied sink function so the * module works with any database or analytics pipeline. */ -import { - FeedbackEntrySchema, - type FeedbackEntry, -} from '@/schemas/feedback.schema'; +import { FeedbackEntrySchema, type FeedbackEntry } from '@/schemas/feedback.schema'; export type FeedbackSink = (entry: FeedbackEntry) => void | Promise;