Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ MIT
- [x] PostgreSQL + deduplicación por URL
- [x] Frontend con buscador y filtros
- [x] Detección de sueldo/rango salarial
- [x] Filtro "solo pegas con sueldo publicado" en el listado (`?sueldo=1`)
- [x] **GetOnBoard** — API pública v0, sin auth, filtrada a Chile/Remoto (nodos `getonbrd-*` en `n8n/workflow.json`, validado con `n8n/test-getonbrd.js`)
- [x] **WorkingNomads** — API pública `/api/exposed_jobs/`, sin auth, filtrada a LatAm/Chile
- [x] Digest de Slack 2x/día (9:00 y 15:00) en vez de notificar en cada corrida — evita saturar el canal
Expand Down
15 changes: 14 additions & 1 deletion web/app/components/PegasFiltros.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ChInput, ChSelect } from '@devschile/chucao/vue';
import { ChCheckbox, ChInput, ChSelect } from '@devschile/chucao/vue';
import { animate, RowValue, useMotionValue, useTransform } from 'motion-v';
import { computed, watch } from 'vue';
import { sourceLabel } from '~/utils/pegas';
Expand All @@ -12,6 +12,7 @@ const props = defineProps<{

const query = defineModel<string>('query', { required: true });
const source = defineModel<string>('source', { required: true });
const withSalary = defineModel<boolean>('withSalary', { required: true });

const sourceOptions = computed(() => [
{ label: 'Todas las fuentes', value: '' },
Expand Down Expand Up @@ -60,6 +61,14 @@ watch(
@ch-change="source = $event.detail ?? $event"
/>
</div>
<div class="filtros__toggle">
<ChCheckbox
label="Solo pegas con sueldo publicado"
hint="La mayoría de los avisos no lo publica."
:checked="withSalary"
@ch-change="withSalary = $event.detail ?? $event"
/>
</div>
</section>
</template>

Expand All @@ -74,6 +83,10 @@ watch(
gap: 1.25rem;
}

.filtros__toggle {
margin-top: 1rem;
}

.filtros__stats {
margin: 0 0 2rem;
text-align: center;
Expand Down
20 changes: 20 additions & 0 deletions web/app/components/__tests__/PegasFiltros.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function mountFilters(props: Partial<InstanceType<typeof PegasFiltros>['$props']
totalGeneral: 10,
query: '',
source: '',
withSalary: false,
...props,
},
});
Expand Down Expand Up @@ -71,4 +72,23 @@ describe('PegasFiltros', () => {

expect(wrapper.emitted('update:source')).toEqual([['linkedin']]);
});

it('emite update:withSalary al marcar el check de sueldo', async () => {
const wrapper = mountFilters();
const salaryCheck = wrapper.findComponent({ name: 'ChCheckbox' });

await salaryCheck.vm.$emit('ch-change', { detail: true });

expect(wrapper.emitted('update:withSalary')).toEqual([[true]]);
});

/** `?? ` solo cae con null/undefined, asi que un detail `false` tiene que llegar como false y no como el evento entero. */
it('emite update:withSalary con false al desmarcarlo', async () => {
const wrapper = mountFilters({ withSalary: true });
const salaryCheck = wrapper.findComponent({ name: 'ChCheckbox' });

await salaryCheck.vm.$emit('ch-change', { detail: false });

expect(wrapper.emitted('update:withSalary')).toEqual([[false]]);
});
});
38 changes: 31 additions & 7 deletions web/app/composables/__tests__/useJobsListing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@ mockNuxtImport('useRouter', () => () => ({
beforeResolve: vi.fn(),
}));

function buildRefs(overrides: Partial<{ query: string; source: string; page: number }> = {}): JobsListingRefs {
function buildRefs(
overrides: Partial<{ query: string; source: string; withSalary: boolean; page: number }> = {},
): JobsListingRefs {
const query = ref(overrides.query ?? '');
return {
query,
debouncedQuery: ref(query.value),
source: ref(overrides.source ?? ''),
withSalary: ref(overrides.withSalary ?? false),
page: ref(overrides.page ?? 1),
};
}
Expand All @@ -54,19 +57,20 @@ describe('createJobsListingStore', () => {
expect(query.value).toBe('');
expect(source.value).toBe('');
expect(page.value).toBe(1);
expect(filters.value).toEqual({ q: '', categoria: '', fuente: '', pagina: 1 });
expect(filters.value).toEqual({ q: '', categoria: '', fuente: '', conSueldo: false, pagina: 1 });
});

it('respeta el estado inicial de los refs (deep-link)', () => {
const { query, source, page, filters } = createJobsListingStore(
buildRefs({ query: 'vue', source: 'getonbrd', page: 3 }),
const { query, source, withSalary, page, filters } = createJobsListingStore(
buildRefs({ query: 'vue', source: 'getonbrd', withSalary: true, page: 3 }),
{ categoriaParam, replaceQuery },
);

expect(query.value).toBe('vue');
expect(source.value).toBe('getonbrd');
expect(withSalary.value).toBe(true);
expect(page.value).toBe(3);
expect(filters.value).toEqual({ q: 'vue', categoria: '', fuente: 'getonbrd', pagina: 3 });
expect(filters.value).toEqual({ q: 'vue', categoria: '', fuente: 'getonbrd', conSueldo: true, pagina: 3 });
});

it('debouncea query 300ms antes de reflejarse en filters', async () => {
Expand Down Expand Up @@ -111,6 +115,18 @@ describe('createJobsListingStore', () => {
expect(page.value).toBe(1);
});

it('vuelve a la pagina 1 cuando se prende el filtro de sueldo', async () => {
const { withSalary, page, nextPage } = createJobsListingStore(buildRefs(), { categoriaParam, replaceQuery });

nextPage();
expect(page.value).toBe(2);

withSalary.value = true;
await nextTick();

expect(page.value).toBe(1);
});

it('vuelve a la pagina 1 cuando cambia la categoria (navegacion de ruta)', async () => {
const { page, nextPage } = createJobsListingStore(buildRefs(), { categoriaParam, replaceQuery });

Expand Down Expand Up @@ -138,15 +154,23 @@ describe('createJobsListingStore', () => {
});

it('sincroniza la query string solo con los parametros activos', async () => {
const { source, nextPage } = createJobsListingStore(buildRefs(), { categoriaParam, replaceQuery });
const { source, withSalary, nextPage } = createJobsListingStore(buildRefs(), { categoriaParam, replaceQuery });

source.value = 'linkedin';
await nextTick();
expect(replaceQuery).toHaveBeenLastCalledWith({ fuente: 'linkedin' });

withSalary.value = true;
await nextTick();
expect(replaceQuery).toHaveBeenLastCalledWith({ fuente: 'linkedin', sueldo: '1' });

nextPage();
await nextTick();
expect(replaceQuery).toHaveBeenLastCalledWith({ fuente: 'linkedin', pagina: '2' });
expect(replaceQuery).toHaveBeenLastCalledWith({ fuente: 'linkedin', sueldo: '1', pagina: '2' });

withSalary.value = false;
await nextTick();
expect(replaceQuery).toHaveBeenLastCalledWith({ fuente: 'linkedin' });
});
});

Expand Down
1 change: 1 addition & 0 deletions web/app/composables/useJobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface JobsFilters {
q: string;
categoria: string;
fuente: string;
conSueldo: boolean;
pagina: number;
}

Expand Down
21 changes: 16 additions & 5 deletions web/app/composables/useJobsListing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ function readStringParam(value: unknown): string {
return typeof value === 'string' ? value : '';
}

/** Solo `?sueldo=1` prende el filtro; cualquier otro valor (o su ausencia) lo deja apagado. */
function readSalaryParam(value: unknown): boolean {
return value === '1';
}

function readPageParam(value: unknown): number {
const page = Number(value);
return Number.isInteger(page) && page > 0 ? page : 1;
Expand All @@ -18,12 +23,13 @@ export interface JobsListingRefs {
query: Ref<string>;
debouncedQuery: Ref<string>;
source: Ref<string>;
withSalary: Ref<boolean>;
page: Ref<number>;
}

interface JobsListingDeps {
categoriaParam: Ref<string | string[] | undefined>;
replaceQuery: (query: { q?: string; fuente?: string; pagina?: string }) => void;
replaceQuery: (query: { q?: string; fuente?: string; sueldo?: string; pagina?: string }) => void;
}

/**
Expand All @@ -35,15 +41,15 @@ interface JobsListingDeps {
* usePegaReactions/createPegaReactionsStore.
*/
export function createJobsListingStore(refs: JobsListingRefs, deps: JobsListingDeps) {
const { query, debouncedQuery, source, page } = refs;
const { query, debouncedQuery, source, withSalary, page } = refs;

const applyDebouncedQuery = debounce((value: string) => {
debouncedQuery.value = value;
}, DEBOUNCE_MS);
watch(query, value => applyDebouncedQuery(value));

/** Cualquier cambio de filtro vuelve a la página 1, igual que en el sitio anterior. */
watch([debouncedQuery, source], () => {
watch([debouncedQuery, source, withSalary], () => {
page.value = 1;
});

Expand All @@ -58,10 +64,11 @@ export function createJobsListingStore(refs: JobsListingRefs, deps: JobsListingD
page.value = 1;
});

watch([debouncedQuery, source, page], ([q, fuente, pagina]) => {
watch([debouncedQuery, source, withSalary, page], ([q, fuente, sueldo, pagina]) => {
deps.replaceQuery({
...(q ? { q } : {}),
...(fuente ? { fuente } : {}),
...(sueldo ? { sueldo: '1' } : {}),
...(pagina > 1 ? { pagina: String(pagina) } : {}),
});
});
Expand All @@ -70,6 +77,7 @@ export function createJobsListingStore(refs: JobsListingRefs, deps: JobsListingD
q: debouncedQuery.value,
categoria: '',
fuente: source.value,
conSueldo: withSalary.value,
pagina: page.value,
}));

Expand All @@ -81,7 +89,7 @@ export function createJobsListingStore(refs: JobsListingRefs, deps: JobsListingD
if (page.value > 1) page.value--;
}

return { query, source, page, filters, nextPage, prevPage };
return { query, source, withSalary, page, filters, nextPage, prevPage };
}

/**
Expand All @@ -104,6 +112,7 @@ export function useJobsListing() {
query: useState('listado-query', () => readStringParam(route.query.q)),
debouncedQuery: useState('listado-debounced-query', () => readStringParam(route.query.q)),
source: useState('listado-source', () => readStringParam(route.query.fuente)),
withSalary: useState('listado-con-sueldo', () => readSalaryParam(route.query.sueldo)),
page: useState('listado-page', () => readPageParam(route.query.pagina)),
};

Expand All @@ -122,12 +131,14 @@ export function useJobsListing() {
export function useJobsListingState() {
const debouncedQuery = useState('listado-debounced-query', () => '');
const source = useState('listado-source', () => '');
const withSalary = useState('listado-con-sueldo', () => false);
const page = useState('listado-page', () => 1);

const filters = computed<JobsFilters>(() => ({
q: debouncedQuery.value,
categoria: '',
fuente: source.value,
conSueldo: withSalary.value,
pagina: page.value,
}));

Expand Down
18 changes: 16 additions & 2 deletions web/app/layouts/__tests__/listado.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@ function buildMeta(overrides: Partial<PegasMeta> = {}): PegasMeta {

const queryRef = ref('');
const sourceRef = ref('');
const withSalaryRef = ref(false);

function mockListing() {
queryRef.value = '';
sourceRef.value = '';
withSalaryRef.value = false;
useJobsListingMock.mockReturnValue({
query: queryRef,
source: sourceRef,
withSalary: withSalaryRef,
page: ref(1),
filters: ref({ q: '', categoria: '', fuente: '', pagina: 1 }),
filters: ref({ q: '', categoria: '', fuente: '', conSueldo: false, pagina: 1 }),
nextPage: vi.fn(),
prevPage: vi.fn(),
});
Expand Down Expand Up @@ -104,14 +107,25 @@ describe('layouts/listado', () => {
expect(wrapper.findComponent({ name: 'CategoriasNav' }).props('active')).toBe('Frontend');
});

it('reset (click en "Todos" de CategoriasNav) limpia query y fuente', async () => {
it('reset (click en "Todos" de CategoriasNav) limpia query, fuente y el filtro de sueldo', async () => {
queryRef.value = 'react';
sourceRef.value = 'linkedin';
withSalaryRef.value = true;
const wrapper = await mountLayout();

await wrapper.findComponent({ name: 'CategoriasNav' }).vm.$emit('reset');

expect(queryRef.value).toBe('');
expect(sourceRef.value).toBe('');
expect(withSalaryRef.value).toBe(false);
});

it('reporta a PostHog cuando se usa el filtro de sueldo', async () => {
await mountLayout();

withSalaryRef.value = true;
await flushPromises();

expect(trackMock).toHaveBeenCalledWith('filtro_usado', { filtro: 'sueldo', valor: 'true' });
});
});
8 changes: 6 additions & 2 deletions web/app/layouts/listado.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ const { data: meta } = await useFetch<PegasMeta>('/api/meta', { key: 'pegas-meta
const categories = computed(() => meta.value?.categorias ?? []);
const sources = computed(() => meta.value?.fuentes ?? []);

const { query, source, filters: baseFilters } = useJobsListing();
const { query, source, withSalary, filters: baseFilters } = useJobsListing();

const track = useTrackEvent();
/** Solo el select (acción discreta), no cada tecla del buscador. */
/** Solo el select y el check (acciones discretas), no cada tecla del buscador. */
watch(source, value => track('filtro_usado', { filtro: 'fuente', valor: value }));
watch(withSalary, value => track('filtro_usado', { filtro: 'sueldo', valor: String(value) }));

/** Página específica que se está mostrando (para resaltar el badge activo en CategoriasNav). */
const activeCategory = computed(() => {
Expand All @@ -45,6 +46,7 @@ const countFilters = computed(() => ({
q: baseFilters.value.q,
categoria: activeCategory.value ?? '',
fuente: source.value,
conSueldo: withSalary.value,
pagina: 1,
porPagina: 1,
}));
Expand All @@ -57,6 +59,7 @@ const totalVisible = computed(() => countData.value?.total ?? 0);
function resetFilters() {
query.value = '';
source.value = '';
withSalary.value = false;
}
</script>

Expand All @@ -65,6 +68,7 @@ function resetFilters() {
<PegasFiltros
v-model:query="query"
v-model:source="source"
v-model:with-salary="withSalary"
:sources="sources"
:total-visible="totalVisible"
:total-general="meta?.total ?? 0"
Expand Down
Loading