frontend/src/routes/stocks/[tickerID]/history/+page.svelte
MuslemRahimi 538b2c92a1 bugfixing
2025-04-16 16:16:01 +02:00

562 lines
22 KiB
Svelte

<script lang="ts">
import { stockTicker, displayCompanyName } from "$lib/store";
import TableHeader from "$lib/components/Table/TableHeader.svelte";
import Infobox from "$lib/components/Infobox.svelte";
import * as DropdownMenu from "$lib/components/shadcn/dropdown-menu/index.js";
import { Button } from "$lib/components/shadcn/button/index.js";
import { goto } from "$app/navigation";
import SEO from "$lib/components/SEO.svelte";
import { onMount } from "svelte";
import { removeCompanyStrings } from "$lib/utils";
import UpgradeToPro from "$lib/components/UpgradeToPro.svelte";
export let data;
let timePeriod = "Daily";
let rawData = data?.getData || [];
let originalData = [];
let stockList = [];
function prepareDataset(data, timePeriod = "Daily") {
if (
timePeriod === "Weekly" ||
timePeriod === "Monthly" ||
timePeriod === "Quarterly" ||
timePeriod === "Annual"
) {
// Group data by week, month, quarter, or year
const aggregatedData = [];
let currentPeriod = null;
data.forEach((entry) => {
const date = new Date(entry.time);
let periodStart;
let periodKey;
if (timePeriod === "Weekly") {
// Calculate the start of the week (Monday)
const dayOfWeek = date.getDay(); // 0 for Sunday, 1 for Monday, etc.
periodStart = new Date(
date.setDate(date.getDate() - ((dayOfWeek + 6) % 7)), // Adjust to get Monday
);
periodKey = periodStart.getTime();
} else if (timePeriod === "Monthly") {
// Use year and month as the period key
periodKey = `${date.getFullYear()}-${date.getMonth()}`;
// Get the last day of the month
periodStart = new Date(date.getFullYear(), date.getMonth() + 1, 0);
} else if (timePeriod === "Quarterly") {
// Calculate quarter (0-3)
const quarter = Math.floor(date.getMonth() / 3);
periodKey = `${date.getFullYear()}-Q${quarter}`;
// Get the last day of the quarter
const lastMonthOfQuarter = (quarter + 1) * 3 - 1;
periodStart = new Date(date.getFullYear(), lastMonthOfQuarter + 1, 0);
} else {
// Annual
periodKey = date.getFullYear().toString();
// Get the last day of the year (December 31st)
periodStart = new Date(date.getFullYear(), 11, 31);
}
if (!currentPeriod || currentPeriod.periodKey !== periodKey) {
// Start a new period
currentPeriod = {
periodStart,
periodKey,
open: entry.open,
adjOpen: entry.adjOpen,
high: entry.high,
adjHigh: entry.adjHigh,
low: entry.low,
adjLow: entry.adjLow,
close: entry.close,
adjClose: entry.adjClose,
volume: entry.volume,
};
aggregatedData.push(currentPeriod);
} else {
// Update the current period's values
// High values should be the maximum observed so far.
currentPeriod.high = Math.max(currentPeriod.high, entry.high);
currentPeriod.adjHigh = Math.max(
currentPeriod.adjHigh,
entry.adjHigh,
);
// Low values should be the minimum observed so far.
currentPeriod.low = Math.min(currentPeriod.low, entry.low);
currentPeriod.adjLow = Math.min(currentPeriod.adjLow, entry.adjLow);
// For close values, use the most recent (current) close.
currentPeriod.close = entry.close;
currentPeriod.adjClose = entry.adjClose;
// Sum volumes.
currentPeriod.volume += entry.volume;
}
});
// Replace Daily data with aggregated data including adjusted values
data = aggregatedData.map((period) => ({
time: period.periodStart.toISOString().split("T")[0],
open: period.open,
adjOpen: period.adjOpen,
high: period.high,
adjHigh: period.adjHigh,
low: period.low,
adjLow: period.adjLow,
close: period.close,
adjClose: period.adjClose,
volume: period.volume,
}));
}
// Process the data to add change and changesPercentage (using non-adjusted close values)
const modifiedData = data?.map((entry, index, arr) => {
if (index === 0) {
return { ...entry, change: null, changesPercentage: null };
}
const previousClose = arr[index - 1]?.close;
const currentClose = entry?.close;
const change = (currentClose - previousClose)?.toFixed(2);
const changesPercentage =
previousClose !== 0
? (((currentClose - previousClose) / previousClose) * 100)?.toFixed(2)
: null;
return { ...entry, changesPercentage };
});
// Sort the data by "time" from latest to earliest
return modifiedData?.sort((a, b) => new Date(b?.time) - new Date(a?.time));
}
async function handleScroll() {
const scrollThreshold = document.body.offsetHeight * 0.8; // 80% of the website height
const isBottom = window.innerHeight + window.scrollY >= scrollThreshold;
if (isBottom && stockList?.length !== originalData?.length) {
const nextIndex = stockList?.length;
const filteredNewResults = originalData?.slice(nextIndex, nextIndex + 50);
stockList = [...stockList, ...filteredNewResults];
}
}
onMount(() => {
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
});
$: columns = [
{ key: "time", label: "Date", align: "left" },
{ key: "open", label: "Open", align: "right" },
{ key: "adjOpen", label: "Adj Open", align: "right" },
{ key: "high", label: "High", align: "right" },
{ key: "adjHigh", label: "Adj High", align: "right" },
{ key: "low", label: "Low", align: "right" },
{ key: "adjLow", label: "Adj Low", align: "right" },
{ key: "close", label: "Close", align: "right" },
{ key: "adjClose", label: "Adj Close", align: "right" },
{ key: "changesPercentage", label: "% Change", align: "right" },
{ key: "volume", label: "Volume", align: "right" },
];
$: sortOrders = {
time: { order: "none", type: "date" },
open: { order: "none", type: "number" },
adjOpen: { order: "none", type: "number" },
high: { order: "none", type: "number" },
adjHigh: { order: "none", type: "number" },
low: { order: "none", type: "number" },
adjLow: { order: "none", type: "number" },
close: { order: "none", type: "number" },
adjClose: { order: "none", type: "number" },
changesPercentage: { order: "none", type: "number" },
volume: { order: "none", type: "number" },
};
const sortData = (key) => {
// Reset all other keys to 'none' except the current key
for (const k in sortOrders) {
if (k !== key) {
sortOrders[k].order = "none";
}
}
// Cycle through 'none', 'asc', 'desc' for the clicked key
const orderCycle = ["none", "asc", "desc"];
const currentOrderIndex = orderCycle.indexOf(sortOrders[key].order);
sortOrders[key].order =
orderCycle[(currentOrderIndex + 1) % orderCycle.length];
const sortOrder = sortOrders[key].order;
// Reset to original data when 'none' and stop further sorting
if (sortOrder === "none") {
originalData = [...rawData]; // Reset originalData to rawData
stockList = originalData?.slice(0, 50); // Reset displayed data
return;
}
// Define a generic comparison function
const compareValues = (a, b) => {
const { type } = sortOrders[key];
let valueA, valueB;
switch (type) {
case "date":
valueA = new Date(a[key]);
valueB = new Date(b[key]);
break;
case "string":
valueA = a[key].toUpperCase();
valueB = b[key].toUpperCase();
return sortOrder === "asc"
? valueA.localeCompare(valueB)
: valueB.localeCompare(valueA);
case "number":
default:
valueA = parseFloat(a[key]);
valueB = parseFloat(b[key]);
break;
}
if (sortOrder === "asc") {
return valueA < valueB ? -1 : valueA > valueB ? 1 : 0;
} else {
return valueA > valueB ? -1 : valueA < valueB ? 1 : 0;
}
};
// Sort using the generic comparison function
stockList = [...originalData].sort(compareValues)?.slice(0, 50);
};
async function exportData() {
if (["Pro", "Plus"]?.includes(data?.user?.tier)) {
let exportList = rawData?.map(
({
time,
open,
adjOpen,
high,
adjHigh,
low,
adjLow,
close,
adjClose,
changesPercentage,
volume,
}) => ({
time,
open,
adjOpen,
high,
adjHigh,
low,
adjLow,
close,
adjClose,
changesPercentage,
volume,
}),
);
const csvRows = [];
// Add headers row
csvRows.push(
"time,open,adjOpen,high,adjHigh,low,adjLow,close,adjClose,changesPercentage,volume",
);
// Add data rows
for (const row of exportList) {
const csvRow = `${row.time},${row.open},${row.adjOpen},${row.high},${row.adjHigh},${row.low},${row.adjLow},${row.close},${row.adjClose},${row.changesPercentage},${row.volume}`;
csvRows.push(csvRow);
}
// Create CSV blob and trigger download
const csv = csvRows.join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.setAttribute("hidden", "");
a.setAttribute("href", url);
a.setAttribute("download", `${$stockTicker}_price_history.csv`);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} else {
goto("/pricing");
}
}
$: {
if (timePeriod) {
rawData = prepareDataset(data?.getData, timePeriod);
originalData = rawData;
stockList = rawData?.slice(0, 50);
}
}
</script>
<SEO
title={`${$displayCompanyName} (${$stockTicker}) Stock Price History`}
description={`Get a complete stock price history for ${$displayCompanyName} (${$stockTicker}), starting from its first trading day. Includes open, high, low, close and volume.`}
/>
<section
class=" overflow-hidden h-full min-h-screen mb-20 sm:mb-0 w-full mt-2 sm:mt-0"
>
<div class="w-full overflow-hidden m-auto mt-5">
<div class="sm:p-0 flex justify-center w-full m-auto overflow-hidden">
<div
class="relative flex justify-center items-start overflow-hidden w-full"
>
<main class="w-full">
<div class="sm:pl-7 sm:pb-7 sm:pt-7 w-full m-auto">
<div
class="flex flex-col sm:flex-row items-start w-full sm:justify-between md:space-x-4 md:border-0 w-full mb-3"
>
<h1 class="text-xl sm:text-2xl font-bold mb-3 sm:mb-0">
{removeCompanyStrings($displayCompanyName)} Stock Price History
</h1>
<div
class="flex flex-row items-center ml-auto w-fit mt-2 sm:mt-0"
>
<div class="relative inline-block text-left ml-auto">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button
builders={[builder]}
class="shadow-sm w-fit border-gray-300 dark:border-gray-600 border sm:hover:bg-gray-100 dark:sm:hover:bg-primary ease-out flex flex-row justify-between items-center px-3 py-2 rounded-md truncate"
>
<span class="truncate px-1">{timePeriod}</span>
<svg
class="-mr-1 ml-1 h-5 w-5 xs:ml-2 inline-block"
viewBox="0 0 20 20"
fill="currentColor"
style="max-width:40px"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-56 h-fit max-h-72 overflow-y-auto scroller"
>
<DropdownMenu.Label
class="text-muted dark:text-gray-400 font-normal"
>
Select time frame
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item
on:click={() => (timePeriod = "Daily")}
class="cursor-pointer sm:hover:bg-gray-300 dark:sm:hover:bg-primary"
>
Daily
</DropdownMenu.Item>
<DropdownMenu.Item
on:click={() => (timePeriod = "Weekly")}
class="cursor-pointer sm:hover:bg-gray-300 dark:sm:hover:bg-primary"
>
Weekly
</DropdownMenu.Item>
{#if !["Pro", "Plus"]?.includes(data?.user?.tier)}
{#each ["Monthly", "Quarterly", "Annual"] as entry}
<DropdownMenu.Item
on:click={() => goto("/pricing")}
class="cursor-pointer sm:hover:bg-gray-300 dark:sm:hover:bg-primary"
>
{entry}
<svg
class="ml-auto -mt-0.5 w-4 h-4 inline-block"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
><path
fill="currentColor"
d="M17 9V7c0-2.8-2.2-5-5-5S7 4.2 7 7v2c-1.7 0-3 1.3-3 3v7c0 1.7 1.3 3 3 3h10c1.7 0 3-1.3 3-3v-7c0-1.7-1.3-3-3-3M9 7c0-1.7 1.3-3 3-3s3 1.3 3 3v2H9z"
/></svg
>
</DropdownMenu.Item>
{/each}
{:else}
<DropdownMenu.Item
on:click={() => (timePeriod = "Monthly")}
class="cursor-pointer sm:hover:bg-gray-300 dark:sm:hover:bg-primary"
>
Monthly
</DropdownMenu.Item>
<DropdownMenu.Item
on:click={() => (timePeriod = "Quarterly")}
class="cursor-pointer sm:hover:bg-gray-300 dark:sm:hover:bg-primary"
>
Quarterly
</DropdownMenu.Item>
<DropdownMenu.Item
on:click={() => (timePeriod = "Annual")}
class="cursor-pointer sm:hover:bg-gray-300 dark:sm:hover:bg-primary"
>
Annual
</DropdownMenu.Item>
{/if}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<Button
on:click={() => exportData()}
class="shadow-sm ml-2 border-gray-300 dark:border-gray-600 border sm:hover:bg-gray-100 dark:sm:hover:bg-primary ease-out flex flex-row justify-between items-center px-3 py-2 rounded-md truncate"
>
<span class="truncate">Download</span>
<svg
class="{['Pro', 'Plus']?.includes(data?.user?.tier)
? 'hidden'
: ''} ml-1 -mt-0.5 w-3.5 h-3.5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
><path
fill="currentColor"
d="M17 9V7c0-2.8-2.2-5-5-5S7 4.2 7 7v2c-1.7 0-3 1.3-3 3v7c0 1.7 1.3 3 3 3h10c1.7 0 3-1.3 3-3v-7c0-1.7-1.3-3-3-3M9 7c0-1.7 1.3-3 3-3s3 1.3 3 3v2H9z"
/></svg
>
</Button>
</div>
</div>
{#if rawData?.length !== 0}
<div class="w-full m-auto">
<div
class="w-full m-auto rounded-none sm:rounded-md mb-4 overflow-x-auto"
>
<table
class="table table-sm table-compact no-scrollbar rounded-none sm:rounded-md w-full border border-gray-300 dark:border-gray-800 m-auto"
>
<thead>
<TableHeader {columns} {sortOrders} {sortData} />
</thead>
<tbody>
{#each stockList as item, index}
<tr
class="dark:sm:hover:bg-[#245073]/10 odd:bg-[#F6F7F8] dark:odd:bg-odd {index +
1 ===
rawData?.length &&
!['Pro', 'Plus']?.includes(data?.user?.tier)
? 'opacity-[0.1]'
: ''}"
>
<td
class="text-start text-sm sm:text-[1rem] whitespace-nowrap"
>
{#if timePeriod === "Weekly"}
Week of {new Date(item?.time).toLocaleString(
"en-US",
{
month: "short",
day: "numeric",
year: "numeric",
timeZone: "Europe/Berlin",
},
)}
{:else}
{new Date(item?.time).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "Europe/Berlin",
})}
{/if}
</td>
<td
class="text-end text-sm sm:text-[1rem] whitespace-nowrap"
>
{item?.open?.toFixed(2)}
</td>
<td
class="text-end text-sm sm:text-[1rem] whitespace-nowrap"
>
{item?.adjOpen !== undefined
? item?.adjOpen?.toFixed(2)
: "n/a"}
</td>
<td
class="text-end text-sm sm:text-[1rem] whitespace-nowrap"
>
{item?.high?.toFixed(2)}
</td>
<td
class="text-end text-sm sm:text-[1rem] whitespace-nowrap"
>
{item?.adjHigh !== undefined && item?.adjHigh
? item?.adjHigh?.toFixed(2)
: "n/a"}
</td>
<td
class="text-end text-sm sm:text-[1rem] whitespace-nowrap"
>
{item?.low?.toFixed(2)}
</td>
<td
class="text-end text-sm sm:text-[1rem] whitespace-nowrap"
>
{item?.adjLow !== undefined && item?.adjLow
? item?.adjLow?.toFixed(2)
: "n/a"}
</td>
<td
class="text-end text-sm sm:text-[1rem] whitespace-nowrap"
>
{item?.close?.toFixed(2)}
</td>
<td
class="text-end text-sm sm:text-[1rem] whitespace-nowrap"
>
{item?.adjClose !== undefined
? item?.adjClose?.toFixed(2)
: "n/a"}
</td>
<td
class="text-sm sm:text-[1rem] {item?.changesPercentage >=
0 && item?.changesPercentage !== null
? "text-green-800 dark:text-[#00FC50] before:content-['+'] "
: item?.changesPercentage < 0 &&
item?.changesPercentage !== null
? 'text-red-800 dark:text-[#FF2F1F]'
: ''} text-end"
>
{item?.changesPercentage !== null
? item?.changesPercentage + "%"
: "n/a"}
</td>
<td
class="text-end text-sm sm:text-[1rem] whitespace-nowrap"
>
{item?.volume?.toLocaleString("en-US")}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<UpgradeToPro {data} />
</div>
{:else}
<Infobox
text={`No price history are available for ${$displayCompanyName}.`}
/>
{/if}
</div>
</main>
</div>
</div>
</div>
</section>