206 lines
6.4 KiB
Vue
206 lines
6.4 KiB
Vue
<template>
|
|
<v-app-bar color="primary">
|
|
<v-app-bar-title>
|
|
Stockingly
|
|
</v-app-bar-title>
|
|
<v-text-field hide-details prepend-icon="mdi-magnify" single-line v-model="searchText"
|
|
placeholder="Search company..." />
|
|
<v-btn icon @click="clearSearch">
|
|
<v-icon>mdi-backspace-outline</v-icon>
|
|
</v-btn>
|
|
<v-menu>
|
|
<template v-slot:activator="{ props }">
|
|
<v-btn variant="text" icon="mdi-sort" v-bind="props"></v-btn>
|
|
</template>
|
|
<v-list>
|
|
<v-list-item v-for="(item, index) in sortItems" :key="index" :active="sortSelected === index" :value="index"
|
|
@click="sortSelected = index">
|
|
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-menu>
|
|
</v-app-bar>
|
|
<v-container class="fill-height">
|
|
<v-row>
|
|
<template v-if="loading" v-for="i in Array.from({ length: 10 }, (_, i) => i)" :key="i">
|
|
<v-col cols="12" md="6" lg="4">
|
|
<v-skeleton-loader class="mx-auto" type="card"></v-skeleton-loader>
|
|
</v-col>
|
|
</template>
|
|
<template v-else>
|
|
<v-col cols="12" md="6" lg="4" v-for="company in filteredCompanies" :key="company.ticker">
|
|
<company-card :company="company" :highlight-tag="searchText" @tag-clicked="setSearch($event)">
|
|
<v-card-actions class="flex-1">
|
|
<v-btn :variant="isSelected(company.ticker) ? 'tonal' : 'text'"
|
|
@click="(isSelected(company.ticker) ? unselect : select)(company.ticker)"
|
|
:color="isSelected(company.ticker) ? 'orange-darken-4' : void (0)">
|
|
{{ isSelected(company.ticker) ? 'Unselect' : 'Select' }}
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</company-card>
|
|
</v-col>
|
|
<v-col cols="12" md="6" lg="4" v-if="filteredCompanies.length === 0">
|
|
<v-card>
|
|
<v-card-text>
|
|
No companies found.
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</template>
|
|
</v-row>
|
|
</v-container>
|
|
<v-sheet class="d-flex align-center justify-center" style="position: fixed; bottom: 0; width: 100%"
|
|
v-if="selected.length > 0" color="blue-grey-lighten-5" :elevation="6">
|
|
<small class="ma-2">{{ selected.length }} / {{ MAX_SELECT }} companies selected:</small>
|
|
<v-chip class="ma-2" color="secondary" closable style="flex: initial !important" v-for="s in selected" :key="s"
|
|
@click:close="unselect(s)">{{ s }}</v-chip>
|
|
<v-btn class="ma-2" variant="flat" size="small" color="primary" prepend-icon="mdi-chart-areaspline-variant"
|
|
@click="goToComparison(selected)">
|
|
Compare
|
|
</v-btn>
|
|
<v-btn class="ma-2" size="small" variant="flat" color="error" @click="selected.splice(0, selected.length)"
|
|
prepend-icon="mdi-delete">
|
|
Cancel
|
|
</v-btn>
|
|
</v-sheet>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { getCompanies, Company } from '@/api';
|
|
import { ref, reactive, watch } from 'vue';
|
|
import debounce from 'debounce';
|
|
import Fuse from 'fuse.js';
|
|
import { useRouter } from 'vue-router';
|
|
import CompanyCard from '@/components/CompanyCard.vue';
|
|
|
|
const MAX_SELECT = 3;
|
|
|
|
interface Sorter {
|
|
title: string,
|
|
sortBy?: keyof Company
|
|
}
|
|
|
|
const sortItems: Sorter[] = [
|
|
{
|
|
title: 'Best Valuation',
|
|
sortBy: 'Valuation',
|
|
},
|
|
{
|
|
title: 'Best Financial Health',
|
|
sortBy: 'Financial Health',
|
|
},
|
|
{
|
|
title: 'Best Estimated Growth',
|
|
sortBy: 'Estimated Growth',
|
|
},
|
|
{
|
|
title: 'Best Past Performance',
|
|
sortBy: 'Past Performance',
|
|
},
|
|
{
|
|
title: 'Biggest Market Cap',
|
|
sortBy: 'market cap'
|
|
},
|
|
{
|
|
title: 'Relevance'
|
|
}
|
|
];
|
|
|
|
const naturalOrder = <T, Key>(getKey: (t: T) => Key) => (a: T, b: T) => {
|
|
const aKey = getKey(a), bKey = getKey(b);
|
|
// return reverse order since we sort in descending order
|
|
return aKey > bKey ? -1 : aKey < bKey ? 1 : 0;
|
|
}
|
|
|
|
const sortSelected = ref<number>(sortItems.length - 1);
|
|
|
|
const loading = ref(true);
|
|
const companies = ref<Company[]>([]);
|
|
const tags = ref<string[]>([]);
|
|
const tickers = ref<string[]>([]);
|
|
|
|
type CompanyWithScore = Company & { score?: number }
|
|
|
|
const filteredCompanies = ref<CompanyWithScore[]>([]);
|
|
const selected = reactive<string[]>([]);
|
|
const searchText = ref("");
|
|
const router = useRouter();
|
|
let fuse: Fuse<Company> | undefined = undefined;
|
|
|
|
const sortCompanies = (companiesToSort: CompanyWithScore[]) => {
|
|
const sorter = sortItems[sortSelected.value]!;
|
|
if (sorter.sortBy !== undefined) {
|
|
const key: keyof Company = sorter.sortBy;
|
|
filteredCompanies.value = companiesToSort.sort(naturalOrder(c => c[key]))
|
|
} else {
|
|
filteredCompanies.value = companiesToSort.sort(naturalOrder(c => c.score ?? c.ticker));
|
|
}
|
|
}
|
|
|
|
const search = debounce(() => setTimeout(() => {
|
|
let companiesToSort: CompanyWithScore[];
|
|
if (tags.value.indexOf(searchText.value) !== -1) {
|
|
companiesToSort = companies.value.filter(e => e.tags.indexOf(searchText.value) !== -1);
|
|
} else {
|
|
const ticker = searchText.value.trim().toUpperCase();
|
|
if (tickers.value.indexOf(ticker) !== -1) {
|
|
companiesToSort = [companies.value.find(c => c.ticker == ticker)!];
|
|
} else {
|
|
companiesToSort = fuse!.search(searchText.value).map(e => ({ ...e.item, score: e.score }));
|
|
}
|
|
}
|
|
sortCompanies(companiesToSort);
|
|
}, 0), 1000);
|
|
|
|
const searchAll = debounce(() => {
|
|
sortCompanies(companies.value);
|
|
}, 1000);
|
|
|
|
watch(sortSelected, () => {
|
|
sortCompanies(filteredCompanies.value)
|
|
});
|
|
|
|
watch(searchText, () => {
|
|
if (!fuse) return;
|
|
loading.value = true;
|
|
if (searchText.value === '') {
|
|
searchAll();
|
|
} else {
|
|
search();
|
|
}
|
|
});
|
|
|
|
const isSelected = (ticker: string) => selected.indexOf(ticker) !== -1;
|
|
|
|
const unselect = (ticker: string) => {
|
|
if (isSelected(ticker)) selected.splice(selected.indexOf(ticker), 1);
|
|
}
|
|
|
|
const select = (ticker: string) => {
|
|
if (!isSelected(ticker) && selected.length < MAX_SELECT) selected.push(ticker);
|
|
}
|
|
|
|
const clearSearch = () => searchText.value = '';
|
|
|
|
const setSearch = (text: string) => searchText.value = text;
|
|
|
|
const goToComparison = (tickers: string[]) => router.push('/comparison/' + tickers.join('/').toUpperCase());
|
|
|
|
watch(filteredCompanies, () => loading.value = false);
|
|
|
|
getCompanies().then(cs => {
|
|
companies.value = cs;
|
|
tags.value = cs.flatMap(c => c.tags);
|
|
tickers.value = cs.map(c => c.ticker);
|
|
const myIndex = Fuse.createIndex([
|
|
{ name: 'company name', weight: 2 },
|
|
'sector',
|
|
'tags',
|
|
{ name: 'ticker', weight: 10 },
|
|
'website'
|
|
], cs);
|
|
fuse = new Fuse(cs, { threshold: 0.6 }, myIndex);
|
|
filteredCompanies.value = cs;
|
|
});
|
|
</script>
|