Hello from MCP server
<template>
<BaseLayout title="Technician - Find Job">
<!-- Toolbar at top of screen -->
<Toolbar
:force-step1="true"
:show-clear="urlSelectedTags.length > 0 || urlSearchTerms.length > 0"
@help-clicked="openInfoModal"
@clear-clicked="handleClear"
/>
<div class="find-job-container">
<SearchBar
:search-terms="urlSearchTerms"
:selected-tags="urlSelectedTags"
:available-tags="localAvailableTagObjects"
:suggestions="[]"
:show-tags="shouldShowSearchBar"
button-text="Find Job"
navigate-path="/find-job"
@navigate="handleNavigate"
/>
<!-- Available jobs list (Google search result style) -->
<div
v-if="urlSelectedTags.length > 0 || urlSearchTerms.length > 0"
class="available-jobs-section"
>
<div v-if="filteredProblems.length === 0" class="no-jobs-message">
<p>
No jobs match your selected filters. Try removing some tags or
changing your search.
</p>
</div>
<div v-else class="search-results">
<SearchResult
v-for="problem in filteredProblems"
:key="problem.id"
:title="problem.name || ''"
:description="problem.description || ''"
@click="selectJob(problem)"
/>
</div>
</div>
</div>
<!-- Info Modal -->
<ion-modal :is-open="isInfoModalOpen" @didDismiss="closeInfoModal">
<ion-header>
<ion-toolbar>
<ion-title>{{ showDebugInfo ? "Tag Information Gain Stats" : "Session Store" }}</ion-title>
<ion-buttons slot="start">
<ion-button @click="toggleDebugInfo">
{{ showDebugInfo ? "Show Info" : "Show Stats" }}
</ion-button>
</ion-buttons>
<ion-buttons slot="end">
<ion-button @click="closeInfoModal">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<!-- Debug Info View -->
<div v-if="showDebugInfo" class="debug-content">
<div class="debug-summary">
<p><strong>Total Problems:</strong> {{ debugInfoData.totalProblems }}</p>
<p><strong>Current Entropy:</strong> {{ debugInfoData.currentEntropy.toFixed(3) }}</p>
<p><strong>Available Tags:</strong> {{ debugInfoData.tags.length }}</p>
</div>
<div class="debug-tags-list">
<div
v-for="tag in debugInfoData.tags"
:key="tag.tagId"
class="debug-tag-item"
>
<div class="debug-tag-header">
<strong>{{ tag.tagName }}</strong>
<span class="debug-tag-gain">Gain: {{ tag.gain.toFixed(3) }}</span>
</div>
<div class="debug-tag-details">
<p>Jobs with tag: {{ tag.countWith }}</p>
<p>Jobs without tag: {{ tag.countWithout }}</p>
<p>Entropy after split: {{ tag.entropy.toFixed(3) }}</p>
</div>
</div>
</div>
</div>
<!-- Normal Info View -->
<div v-else class="info-content">
<pre>{{ JSON.stringify(sessionStore.$state, null, 2) }}</pre>
</div>
</ion-content>
</ion-modal>
</BaseLayout>
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import {
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonContent,
} from "@ionic/vue";
import BaseLayout from "@/components/BaseLayout.vue";
import Toolbar from "@/components/Toolbar.vue";
import SearchBar from "@/components/SearchBar.vue";
import SearchResult from "@/components/SearchResult.vue";
import { useSessionStore } from "@/stores/session";
import { useRouter, useRoute } from "vue-router";
import { useJobsAndTags } from "@/composables/useJobsAndTags";
import type { ProblemsRecord } from "@/pocketbase-types";
import { ENTROPY_CUTOFF_FOR_TAGS } from "@/constants";
const router = useRouter();
const route = useRoute();
const sessionStore = useSessionStore();
const isInfoModalOpen = ref(false);
const showDebugInfo = ref(false);
// Use composable for jobs and tags logic
const {
problems,
problemTags,
availableTagObjects,
selectedTagObjects,
appliedSearchTerms,
getProblemTagIds
} = useJobsAndTags();
// Local state for URL-based search terms and tags
const urlSearchTerms = ref<string[]>([]);
const urlSelectedTags = ref<{ id: string; name: string }[]>([]);
// Function to parse URL and update state
const parseUrlAndUpdateState = () => {
console.log('[FindJob] Parsing URL, problemTags available:', problemTags.value.length);
// Parse search terms from 'q' parameter
const qParam = route.query.q?.toString() || '';
if (qParam) {
urlSearchTerms.value = qParam.split(' ').filter(term => term.trim() !== '');
} else {
urlSearchTerms.value = [];
}
// Parse tags from 'tags' parameter
const tagsParam = route.query.tags?.toString() || '';
if (tagsParam) {
const tagNames = tagsParam.split(',').filter(name => name.trim() !== '');
// Convert tag names to tag objects by looking them up
urlSelectedTags.value = tagNames
.map(tagName => {
const tag = problemTags.value.find(t => t.name === tagName);
return tag ? { id: tag.id, name: tag.name || '' } : null;
})
.filter(tag => tag !== null) as { id: string; name: string }[];
} else {
urlSelectedTags.value = [];
}
console.log('[FindJob] Parsed URL state:', {
searchTerms: urlSearchTerms.value,
selectedTags: urlSelectedTags.value
});
};
// Watch URL changes and parse query parameters
watch(
() => route.query,
(newQuery) => {
console.log('[FindJob] URL query changed:', newQuery);
parseUrlAndUpdateState();
},
{ immediate: true }
);
// Watch problemTags and re-parse URL when they're loaded
watch(
() => problemTags.value,
(tags) => {
// Only re-parse if tags are now available and we have tags in the URL
if (tags.length > 0 && route.query.tags) {
console.log('[FindJob] Problem tags loaded, re-parsing URL');
parseUrlAndUpdateState();
}
}
);
// Create local deep copy of problems data
const availableJobs = ref<ProblemsRecord[]>([]);
// Watch problems and create deep copy
watch(
() => problems.value,
(newProblems) => {
// Create deep copy using JSON parse/stringify
availableJobs.value = JSON.parse(JSON.stringify(newProblems));
console.log('[FindJob] Created deep copy of problems:', availableJobs.value.length);
},
{ immediate: true }
);
// Filtered problems based on URL state
const filteredProblems = computed(() => {
let filtered = availableJobs.value;
// Filter by selected tags from URL
if (urlSelectedTags.value.length > 0) {
filtered = filtered.filter((problem) => {
const problemTagIds = getProblemTagIds(problem);
// Job must have ALL selected tags (AND logic)
return urlSelectedTags.value.every((tag) => problemTagIds.includes(tag.id));
});
}
// Filter by search terms from URL
if (urlSearchTerms.value.length > 0) {
filtered = filtered.filter((problem) => {
const problemName = problem.name?.toLowerCase() || "";
const problemDescription = problem.description?.toLowerCase() || "";
const searchText = `${problemName} ${problemDescription}`;
// All search terms must be found in the combined text (AND logic)
return urlSearchTerms.value.every((term) =>
searchText.includes(term.toLowerCase())
);
});
}
console.log(
"[FindJob] Filtered problems:",
"searchTerms:", urlSearchTerms.value,
"selectedTags:", urlSelectedTags.value.length,
"result count:", filtered.length
);
return filtered;
});
// Available tags based on current job set (excludes selected tags)
const localAvailableTagObjects = computed(() => {
if (problemTags.value.length === 0 || availableJobs.value.length === 0) {
return [];
}
// Determine which jobs to use for tag calculation
// If we have filters applied, use filtered results
// Otherwise use all available jobs
const hasFilters = urlSelectedTags.value.length > 0 || urlSearchTerms.value.length > 0;
const jobsToUse = hasFilters ? filteredProblems.value : availableJobs.value;
if (jobsToUse.length === 0) {
return [];
}
// Get IDs of selected tags
const selectedTagIds = urlSelectedTags.value.map(tag => tag.id);
// Count frequency of each tag in the jobs
const tagFrequency = new Map<string, { id: string; name: string; count: number }>();
jobsToUse.forEach((problem) => {
const problemTagIds = getProblemTagIds(problem);
problemTagIds.forEach((tagId) => {
// Skip if tag is already selected
if (selectedTagIds.includes(tagId)) return;
const tag = problemTags.value.find(t => t.id === tagId);
if (!tag) return;
if (tagFrequency.has(tagId)) {
tagFrequency.get(tagId)!.count++;
} else {
tagFrequency.set(tagId, {
id: tag.id,
name: tag.name || '',
count: 1
});
}
});
});
console.log('[FindJob] Available tags calculation:', {
hasFilters,
jobsCount: jobsToUse.length,
uniqueTags: tagFrequency.size,
selectedTagIds
});
// Sort by frequency (descending) and take top 15
return Array.from(tagFrequency.values())
.sort((a, b) => b.count - a.count)
.slice(0, 15)
.map(item => ({ id: item.id, name: item.name }));
});
// Format description with hashtags
const formatDescriptionWithHashtags = (description: string | undefined) => {
if (!description) return "";
// Find the first # and style everything from there onwards
const hashIndex = description.indexOf("#");
if (hashIndex === -1) return description;
const beforeHash = description.substring(0, hashIndex);
const afterHash = description.substring(hashIndex);
return beforeHash + '<span class="hashtag">' + afterHash + "</span>";
};
// Select job and navigate to detail page
const selectJob = (problem: ProblemsRecord) => {
console.log("Selected job:", problem.name);
// Navigate to confirm job page without animation
router.push({
path: `/confirm-job/${problem.id}`,
// @ts-ignore - Ionic router options
routerDirection: "none",
});
};
const openInfoModal = () => {
isInfoModalOpen.value = true;
showDebugInfo.value = false;
};
const closeInfoModal = () => {
isInfoModalOpen.value = false;
showDebugInfo.value = false;
};
const toggleDebugInfo = () => {
showDebugInfo.value = !showDebugInfo.value;
};
// Current entropy for filtering decisions
const currentEntropy = computed(() => {
const totalProblems = filteredProblems.value.length > 0
? filteredProblems.value.length
: availableJobs.value.length;
return totalProblems > 1 ? Math.log2(totalProblems) : 0;
});
// Should we show the search bar and tags?
const shouldShowSearchBar = computed(() => {
const show = currentEntropy.value >= ENTROPY_CUTOFF_FOR_TAGS;
console.log('[FindJob] Entropy check:', {
currentEntropy: currentEntropy.value,
cutoff: ENTROPY_CUTOFF_FOR_TAGS,
shouldShowSearchBar: show
});
return show;
});
// Calculate information gain stats for debug view
const debugInfoData = computed(() => {
const totalProblems = filteredProblems.value.length > 0
? filteredProblems.value.length
: availableJobs.value.length;
const tags = localAvailableTagObjects.value.map(tag => {
const jobsToUse = filteredProblems.value.length > 0
? filteredProblems.value
: availableJobs.value;
// Count jobs with this tag
const countWith = jobsToUse.filter(problem => {
const tagIds = getProblemTagIds(problem);
return tagIds.includes(tag.id);
}).length;
const countWithout = totalProblems - countWith;
// Calculate entropy after split
let entropyAfterSplit = 0;
if (countWith > 0) {
const probWith = countWith / totalProblems;
const entropyWith = countWith > 1 ? Math.log2(countWith) : 0;
entropyAfterSplit += probWith * entropyWith;
}
if (countWithout > 0) {
const probWithout = countWithout / totalProblems;
const entropyWithout = countWithout > 1 ? Math.log2(countWithout) : 0;
entropyAfterSplit += probWithout * entropyWithout;
}
const informationGain = currentEntropy.value - entropyAfterSplit;
return {
tagId: tag.id,
tagName: tag.name,
countWith,
countWithout,
gain: informationGain,
entropy: entropyAfterSplit
};
});
return {
totalProblems,
currentEntropy: currentEntropy.value,
tags
};
});
const handleNavigate = (url: string) => {
router.push(url);
};
const handleClear = () => {
// Clear all filters by navigating to /find-job with no params
router.push('/find-job');
};
</script>
<style scoped>
:deep(.ion-page) {
animation: none !important;
transform: none !important;
transition: none !important;
}
.find-job-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
animation: none !important;
transform: none !important;
transition: none !important;
}
/* Available jobs list styles */
.available-jobs-section {
margin-top: 24px;
width: 100%;
}
.search-results {
display: flex;
flex-direction: column;
gap: 0;
}
.no-jobs-message {
text-align: center;
padding: 20px;
color: var(--ion-color-medium);
}
.no-jobs-message p {
margin: 0;
font-size: 14px;
}
/* Mobile adjustments */
@media (max-width: 767px) {
.find-job-container {
padding: 16px;
}
.find-job-title {
font-size: 28px;
}
.description {
font-size: 16px;
}
}
/* Info Modal Styles */
.info-content {
color: var(--ion-color-dark);
}
.info-content pre {
background: var(--ion-color-light);
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Debug Info Styles */
.debug-content {
color: var(--ion-color-dark);
}
.debug-summary {
background: var(--ion-color-light);
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
}
.debug-summary p {
margin: 8px 0;
font-size: 14px;
}
.debug-tags-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.debug-tag-item {
background: var(--ion-color-light);
padding: 16px;
border-radius: 8px;
border-left: 4px solid var(--ion-color-primary);
}
.debug-tag-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.debug-tag-header strong {
font-size: 16px;
color: var(--ion-color-dark);
}
.debug-tag-gain {
font-size: 14px;
font-weight: 600;
color: var(--ion-color-primary);
}
.debug-tag-details p {
margin: 4px 0;
font-size: 13px;
color: var(--ion-color-medium);
}
</style>