Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
<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>