2025-01-26 17:17:37 +00:00
|
|
|
import clsx from "clsx"
|
2025-01-26 17:49:32 +00:00
|
|
|
import { useEffect, useMemo, useRef, useState } from "react"
|
2025-01-26 01:22:33 +00:00
|
|
|
import Chart from "react-google-charts"
|
|
|
|
import { Button } from "~/components/button"
|
2025-01-26 17:17:37 +00:00
|
|
|
import { ApplicationList } from "~/home/application-list"
|
|
|
|
import type { Node } from "~/home/graph"
|
2025-01-26 18:06:46 +00:00
|
|
|
import { useRootStore } from "~/home/store"
|
2025-01-26 01:22:33 +00:00
|
|
|
import { Queue } from "~/queue"
|
|
|
|
import { useUiMode } from "~/use-ui-mode"
|
2025-01-25 18:16:30 +00:00
|
|
|
|
2025-01-26 17:17:37 +00:00
|
|
|
export function meta() {
|
2025-01-26 01:22:33 +00:00
|
|
|
return [
|
|
|
|
{ title: "TrackMyApp" },
|
2025-01-27 00:41:52 +00:00
|
|
|
{
|
|
|
|
name: "description",
|
|
|
|
content: "Track and visualize your job applications and more.",
|
|
|
|
},
|
2025-01-26 01:22:33 +00:00
|
|
|
]
|
2025-01-25 18:16:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export default function Home() {
|
2025-01-26 22:56:20 +00:00
|
|
|
const loadGraphFromLocalStorage = useRootStore(
|
|
|
|
(state) => state.loadGraphFromLocalStorage,
|
|
|
|
)
|
|
|
|
|
|
|
|
useEffect(function saveGraphToLocalStorage() {
|
|
|
|
const unsub = useRootStore.subscribe(({ nodes, starts, entries }) => {
|
|
|
|
localStorage.setItem("graph", JSON.stringify({ nodes, starts, entries }))
|
|
|
|
})
|
|
|
|
return () => {
|
|
|
|
unsub()
|
|
|
|
}
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!loadGraphFromLocalStorage()) {
|
|
|
|
alert("Unable to load locally saved data due to inconsistency.")
|
|
|
|
}
|
|
|
|
}, [loadGraphFromLocalStorage])
|
|
|
|
|
2025-01-26 01:22:33 +00:00
|
|
|
return (
|
|
|
|
<div className="flex items-center justify-center p-4 md:px-16 md:pt-20 w-full">
|
|
|
|
<main className="w-full max-w-xl flex flex-col items-start">
|
|
|
|
<header>
|
|
|
|
<h1 className="text-2xl font-bold">TrackMyApp</h1>
|
|
|
|
</header>
|
|
|
|
|
2025-01-26 17:17:37 +00:00
|
|
|
<FlufferChart />
|
2025-01-26 01:22:33 +00:00
|
|
|
|
|
|
|
<h2 className="text-lg font-bold my-4">My Applications</h2>
|
|
|
|
|
|
|
|
<ApplicationList />
|
|
|
|
<AddApplicationForm />
|
|
|
|
</main>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2025-01-26 17:17:37 +00:00
|
|
|
function FlufferChart() {
|
2025-01-26 18:06:46 +00:00
|
|
|
const nodes = useRootStore((state) => state.nodes)
|
|
|
|
const starts = useRootStore((state) => state.starts)
|
2025-01-26 22:56:20 +00:00
|
|
|
const resetGraph = useRootStore((state) => state.resetGraph)
|
2025-01-26 01:22:33 +00:00
|
|
|
const uiMode = useUiMode()
|
|
|
|
|
|
|
|
const data = useMemo(() => {
|
2025-01-26 22:56:20 +00:00
|
|
|
try {
|
|
|
|
const rows: [string, string, number][] = []
|
|
|
|
const queue = new Queue<Node>()
|
|
|
|
for (const nodeKey of starts) {
|
|
|
|
queue.enqueue(nodes[nodeKey])
|
|
|
|
}
|
|
|
|
while (!queue.isEmpty) {
|
|
|
|
// biome-ignore lint/style/noNonNullAssertion: if queue is non empty, then dequeue will always be non null
|
|
|
|
const currentNode = queue.dequeue()!
|
|
|
|
for (const nodeKey in currentNode.outs) {
|
|
|
|
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
|
|
|
const connection = currentNode.outs[nodeKey]!
|
|
|
|
rows.push([currentNode.key, connection.nodeKey, connection.weight])
|
|
|
|
queue.enqueue(nodes[connection.nodeKey])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return rows
|
|
|
|
} catch {
|
|
|
|
if (confirm("Invalid data detected. Erase data and reset?")) {
|
|
|
|
resetGraph()
|
2025-01-26 01:22:33 +00:00
|
|
|
}
|
2025-01-26 22:56:20 +00:00
|
|
|
return []
|
2025-01-26 01:22:33 +00:00
|
|
|
}
|
2025-01-26 22:56:20 +00:00
|
|
|
}, [starts, nodes, resetGraph])
|
2025-01-26 01:22:33 +00:00
|
|
|
|
|
|
|
const hasData = data.length > 0
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="w-full relative my-4">
|
|
|
|
<Chart
|
|
|
|
className={clsx(hasData ? "" : "invisible", "text-white")}
|
|
|
|
width="100%"
|
|
|
|
chartType="Sankey"
|
|
|
|
data={[["From", "To", "Weight"], ...data]}
|
|
|
|
options={{
|
|
|
|
sankey: {
|
|
|
|
node: {
|
|
|
|
label: { color: uiMode === "dark" ? "#fff" : "#000" },
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
{hasData ? null : (
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center rounded border dark:border-neutral-800">
|
|
|
|
<p className="opacity-50">Enter some data to see the graph here.</p>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
function AddApplicationForm() {
|
|
|
|
const [isAddingEntry, setIsAddingEntry] = useState(false)
|
2025-01-26 18:06:46 +00:00
|
|
|
const addEntry = useRootStore((state) => state.addEntry)
|
|
|
|
const hasEntry = useRootStore((state) => state.hasEntry)
|
2025-01-26 01:22:33 +00:00
|
|
|
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
|
|
|
2025-01-26 17:49:32 +00:00
|
|
|
useEffect(() => {
|
|
|
|
if (isAddingEntry) {
|
|
|
|
inputRef.current?.focus()
|
|
|
|
}
|
|
|
|
}, [isAddingEntry])
|
|
|
|
|
2025-01-26 01:22:33 +00:00
|
|
|
function onAddButtonClick() {
|
|
|
|
if (!isAddingEntry) {
|
|
|
|
setIsAddingEntry(true)
|
|
|
|
} else if (inputRef.current) {
|
|
|
|
const entryName = inputRef.current.value
|
|
|
|
if (hasEntry(entryName)) {
|
|
|
|
alert(`There is already an application named ${entryName}!`)
|
|
|
|
} else {
|
|
|
|
addEntry(entryName)
|
|
|
|
setIsAddingEntry(false)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function cancelEntry() {
|
|
|
|
setIsAddingEntry(false)
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="mt-2">
|
|
|
|
{isAddingEntry ? (
|
|
|
|
<input
|
|
|
|
ref={inputRef}
|
|
|
|
className="px-2"
|
|
|
|
type="text"
|
|
|
|
placeholder="Application name"
|
2025-01-26 17:43:29 +00:00
|
|
|
onKeyDown={(event) => {
|
|
|
|
if (event.key === "Enter") {
|
|
|
|
onAddButtonClick()
|
|
|
|
}
|
|
|
|
}}
|
2025-01-26 01:22:33 +00:00
|
|
|
/>
|
|
|
|
) : null}
|
|
|
|
<div className="flex flex-row space-x-2 mt-2">
|
|
|
|
<Button onClick={onAddButtonClick}>
|
|
|
|
{isAddingEntry ? "Add" : "Add application"}
|
|
|
|
</Button>
|
|
|
|
{isAddingEntry ? <Button onClick={cancelEntry}>Cancel</Button> : null}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)
|
2025-01-25 18:16:30 +00:00
|
|
|
}
|