From f43ede9e078f56e74b853e7a290c82a06b4ec90a Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 30 Jul 2026 18:34:42 +0000 Subject: [PATCH] W3: add red-flag badges + nudge dots to kanban, interview-prep modal to detail, fetch form + scam column to Research --- apps/web/src/views/ApplicationDetail.vue | 33 ++++++++ apps/web/src/views/Applications.vue | 52 ++++++++++++- apps/web/src/views/Research.vue | 95 ++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/apps/web/src/views/ApplicationDetail.vue b/apps/web/src/views/ApplicationDetail.vue index 58c5cae..bf0580b 100644 --- a/apps/web/src/views/ApplicationDetail.vue +++ b/apps/web/src/views/ApplicationDetail.vue @@ -3,6 +3,7 @@ import { onMounted, ref, computed } from 'vue' import { useToastStore } from '@/stores/toast' import * as api from '@/api' import { HttpError } from '@/api' +import InterviewPrepModal from '@/components/InterviewPrepModal.vue' import type { Application, Artifact, @@ -32,6 +33,9 @@ const requestingApproval = ref(false) const confirming = ref(false) const sending = ref(false) +// Interview prep modal +const showPrepModal = ref(false) + const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false) const canSend = computed(() => isConfirmed.value && !sending.value) @@ -127,6 +131,14 @@ async function sendOutbox() { } } +function openInterviewPrep() { + showPrepModal.value = true +} + +function closeInterviewPrep() { + showPrepModal.value = false +} + onMounted(loadData) @@ -147,6 +159,20 @@ onMounted(loadData) + +
+

Interview Prep

+

+ Generate likely interview questions with suggested answers based on your profile and the posting. +

+ +
+

Artifacts

@@ -247,5 +273,12 @@ onMounted(loadData)
Application not found.
+ + + \ No newline at end of file diff --git a/apps/web/src/views/Applications.vue b/apps/web/src/views/Applications.vue index 5361978..6998b28 100644 --- a/apps/web/src/views/Applications.vue +++ b/apps/web/src/views/Applications.vue @@ -4,7 +4,7 @@ import { useRouter } from 'vue-router' import { useToastStore } from '@/stores/toast' import * as api from '@/api' import { HttpError } from '@/api' -import type { Application, ApplicationState } from '@/types' +import type { Application, ApplicationState, TodayNudge } from '@/types' const toast = useToastStore() const router = useRouter() @@ -14,6 +14,10 @@ const loading = ref(true) const draggingId = ref(null) const draggingFrom = ref(null) +// Red flags and nudges +const redFlagsMap = ref>({}) +const nudgeIds = ref>(new Set()) + const states: ApplicationState[] = [ 'discovered', 'scored', @@ -31,9 +35,39 @@ function appsInState(state: ApplicationState): Application[] { return applications.value.filter((a) => a.state === state) } +function hasRedFlags(app: Application): boolean { + const flags = redFlagsMap.value[app.id] + return flags != null && flags.length > 0 +} + +function redFlagsFor(app: Application): string[] { + return redFlagsMap.value[app.id] ?? [] +} + +function hasNudge(app: Application): boolean { + return nudgeIds.value.has(app.id) +} + async function loadApplications() { try { applications.value = await api.getApplications() + // Load red flags via batch scoring and nudges via today endpoint + const [batchResult, todayResult] = await Promise.allSettled([ + api.batchScore(applications.value.map((a) => a.id)), + api.getToday() + ]) + if (batchResult.status === 'fulfilled') { + const map: Record = {} + for (const r of batchResult.value.results) { + if (r.red_flags && r.red_flags.length > 0) { + map[r.application_id] = r.red_flags + } + } + redFlagsMap.value = map + } + if (todayResult.status === 'fulfilled') { + nudgeIds.value = new Set(todayResult.value.nudges.map((n: TodayNudge) => n.application_id)) + } } catch { toast.push('Failed to load applications', 'error') } finally { @@ -117,7 +151,21 @@ onMounted(loadApplications) @click="goToDetail(app)" class="bg-white rounded border border-gray-200 p-2 cursor-pointer hover:shadow-md transition-shadow" > -
{{ app.posting?.company ?? 'Unknown' }}
+
+ + ⚠ + + +
{{ app.posting?.company ?? 'Unknown' }}
+
{{ app.posting?.title ?? 'No title' }}
Score: {{ app.score }} diff --git a/apps/web/src/views/Research.vue b/apps/web/src/views/Research.vue index 5ddc1a3..cf40d6a 100644 --- a/apps/web/src/views/Research.vue +++ b/apps/web/src/views/Research.vue @@ -2,6 +2,7 @@ import { onMounted, ref } from 'vue' import { useToastStore } from '@/stores/toast' import * as api from '@/api' +import { HttpError } from '@/api' import type { JobPosting } from '@/types' const toast = useToastStore() @@ -11,10 +12,32 @@ const loading = ref(true) const newUrl = ref('') const scoringId = ref(null) const scoreMap = ref }>>({}) +const redFlagsMap = ref>({}) + +// Fetch form +const fetchQuery = ref('') +const fetchRegion = ref('') +const fetching = ref(false) +const fetchResult = ref<{ new: number; dupes: number } | null>(null) async function loadPostings() { try { postings.value = await api.getPostings() + // Load red flags for existing postings via batch scoring + if (postings.value.length > 0) { + try { + const batch = await api.batchScore(postings.value.map((p) => p.id)) + const map: Record = {} + for (const r of batch.results) { + if (r.red_flags && r.red_flags.length > 0) { + map[r.application_id] = r.red_flags + } + } + redFlagsMap.value = map + } catch { + // batch scoring is optional; ignore errors + } + } } catch { toast.push('Failed to load postings', 'error') } finally { @@ -34,6 +57,27 @@ async function addPosting() { } } +async function doFetch() { + if (!fetchQuery.value.trim()) return + fetching.value = true + fetchResult.value = null + try { + fetchResult.value = await api.fetchPostings(fetchQuery.value.trim(), fetchRegion.value.trim() || undefined) + toast.push(`Fetched ${fetchResult.value.new} new postings`, 'success') + // Reload postings to show new ones + await loadPostings() + } catch (err) { + let msg = 'Fetch failed' + if (err instanceof HttpError) { + const body = err.body as { error?: { message?: string } } | null + msg = body?.error?.message ?? msg + } + toast.push(msg, 'error') + } finally { + fetching.value = false + } +} + async function scorePosting(p: JobPosting) { scoringId.value = p.id try { @@ -47,6 +91,15 @@ async function scorePosting(p: JobPosting) { } } +function hasScamFlag(p: JobPosting): boolean { + const flags = redFlagsMap.value[p.id] + return flags != null && flags.length > 0 +} + +function scamFlagsFor(p: JobPosting): string[] { + return redFlagsMap.value[p.id] ?? [] +} + onMounted(loadPostings) @@ -54,6 +107,37 @@ onMounted(loadPostings)

Research

+ +
+

Fetch from Arbetsformedlingen

+
+ + + +
+
+ {{ fetchResult.new }} new, + {{ fetchResult.dupes }} duplicates +
+
+ +
Title Location Source + Scam Fetched Actions @@ -83,6 +168,16 @@ onMounted(loadPostings) {{ p.title }} {{ p.location }} {{ p.source }} + + + ⚠ + + - + {{ p.fetched_at?.slice(0, 10) }}