Skip to content

Commit 1762ee5

Browse files
committed
integrate time to confidence color coding + QoL improvements
1 parent e0b5ca9 commit 1762ee5

File tree

10 files changed

+216
-157
lines changed

10 files changed

+216
-157
lines changed

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@radix-ui/react-toggle": "^1.1.1",
1919
"class-variance-authority": "^0.7.1",
2020
"clsx": "^2.1.1",
21+
"date-fns": "^4.1.0",
2122
"firebase": "^11.1.0",
2223
"lucide-react": "^0.468.0",
2324
"react": "^18.3.1",
Lines changed: 70 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,81 @@
1-
import { Pin, PinOff } from "lucide-react";
2-
import clsx from "clsx";
1+
import { useMemo } from "react";
2+
import { DatabaseBackup } from "lucide-react";
33

4-
import { useStorage } from "../../hooks/useStorage";
4+
import { convert } from "../../utils/convert";
5+
import { useCurrencyMapData } from "../../hooks/useCurrencyMap";
56

7+
import { PinButton } from "./PinButton";
68
import { AmountDisplay } from "../AmountDisplay";
9+
import { ConfidenceColor } from "./ConfidenceColor";
10+
import { ColorInfo } from "./Informational/ColorInfo";
711

8-
function PinButton({ primary, secondary }: { primary: CurrencyKey; secondary: CurrencyKey }) {
9-
const {
10-
setPreferences,
11-
preferences: { pinned }
12-
} = useStorage();
13-
14-
const isCurrentlyPinned = pinned?.primary === primary && pinned?.secondary === secondary;
15-
16-
return (
17-
<button
18-
title={isCurrentlyPinned ? "Unpin" : "Pin to top"}
19-
type='button'
20-
className={clsx(
21-
"hover:text-primary-main transition-colors",
22-
isCurrentlyPinned ? "text-primary-main" : "text-primary-dark"
23-
)}
24-
onClick={() => {
25-
setPreferences(
26-
isCurrentlyPinned
27-
? { pinned: null }
28-
: {
29-
pinned: {
30-
primary: primary,
31-
secondary: secondary
32-
}
33-
}
34-
);
35-
}}>
36-
{isCurrentlyPinned ? <PinOff className='w-4 h-4' /> : <Pin className='w-4 h-4' />}
37-
</button>
38-
);
39-
}
12+
import { currencies } from "../../constant";
4013

4114
type Props = {
42-
primary: CurrencyKey;
43-
results: ConversionResults;
15+
selected: CurrencyKey;
16+
value: string;
4417
};
4518

46-
export const CalculationResults = ({ primary, results }: Props) => (
47-
<div className='flex flex-col w-max self-start'>
48-
{results.conversions.map((res) => (
49-
<div key={res.currency} className='mt-[-4px] flex gap-2 w-full items-center relative'>
50-
<PinButton primary={primary} secondary={res.currency} />
19+
export const CalculationResults = ({ selected, value }: Props) => {
20+
const currencyMap = useCurrencyMapData()!;
21+
22+
const results = useMemo(() => {
23+
const values: ConversionResults = { conversions: [], highestConfidence: 0 };
24+
if (!selected || !currencyMap) {
25+
return values;
26+
}
27+
28+
try {
29+
for (const currency of currencies.filter((c) => c !== selected)) {
30+
const conversion = convert(selected, currency, currencyMap);
31+
32+
if (conversion.rate == null) {
33+
continue;
34+
}
35+
36+
if (conversion.confidence && conversion.confidence > values.highestConfidence) {
37+
values.highestConfidence = conversion.confidence;
38+
}
5139

52-
<div
53-
className='flex flex-row items-center gap-1'
54-
title={`Based on the collected data, confidence rating for this calculation is: ${res.confidence}%`}>
55-
<div
56-
className='flex items-center justify-center min-w-[2px] max-w-[2px] min-h-[20px] overflow-hidden text-transparent font-[FontinBold] text-nowrap text-xs select-none transition-all hover:min-w-[104px] hover:text-white hover:px-1'
57-
style={{ backgroundColor: `hsl(${(res.confidence / results.highestConfidence) * 120}, 70%, 50%)` }}>
58-
<span>Confidence: {res.confidence}%</span>
59-
</div>
60-
<AmountDisplay rate={res.calculation} currencyName={res.currency} />
40+
values.conversions.push({
41+
currency,
42+
calculation: value ? parseFloat(value) * conversion.rate : 0,
43+
confidence: conversion.confidence ?? 0
44+
});
45+
}
46+
} catch (e) {
47+
console.error(e);
48+
}
49+
50+
return values;
51+
}, [selected, value, currencyMap]);
52+
53+
return (
54+
<div className='flex flex-col gap-2'>
55+
{selected && (
56+
<div className='flex flex-col w-max self-start'>
57+
{results.conversions.map((res) => (
58+
<div key={res.currency} className='mt-[-4px] flex gap-2 w-full items-center relative'>
59+
<PinButton primary={selected} secondary={res.currency} />
60+
61+
<div
62+
className='flex flex-row items-center gap-1'
63+
title={`Based on the collected data, confidence rating for this calculation is: ${res.confidence}%`}>
64+
<ConfidenceColor confidence={res.confidence} highestConfidence={results.highestConfidence} />
65+
<AmountDisplay rate={res.calculation} currencyName={res.currency} />
66+
</div>
67+
</div>
68+
))}
6169
</div>
70+
)}
71+
72+
<div className='flex flex-col gap-2'>
73+
<ColorInfo />
74+
75+
<p className='flex items-center gap-1 text-selected-dark italic text-xs'>
76+
<DatabaseBackup className='w-4 h-4' /> Last Updated: {new Date(currencyMap.meta.createdAt).toLocaleString()}
77+
</p>
6278
</div>
63-
))}
64-
</div>
65-
);
79+
</div>
80+
);
81+
};

src/components/CalculationView/CalculationView.tsx

Lines changed: 2 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,21 @@
1-
import { useMemo, useState } from "react";
2-
import { DatabaseBackup } from "lucide-react";
1+
import { useState } from "react";
32
import clsx from "clsx";
43

5-
import { convert } from "../../utils/convert";
64
import { useStorage } from "../../hooks/useStorage";
75
import { useCurrencyMapData } from "../../hooks/useCurrencyMap";
86

97
import { Gears } from "../Gears";
108
import { CurrencySelection } from "./CurrencySelection";
119
import { CalculationResults } from "./CalculationResults";
1210
import { CurrencyInput } from "./CurrencyInput";
13-
import { ColorInfo } from "./Informational/ColorInfo";
1411
import ErrorBoundary from "../ErrorBoundary";
1512

16-
import { currencies } from "../../constant";
17-
1813
export function CalculationView() {
1914
const { preferences } = useStorage();
2015
const [selected, setSelected] = useState<CurrencyKey | "">(preferences.starred ?? "");
2116
const [value, setValue] = useState("1");
2217
const currencyMap = useCurrencyMapData();
2318

24-
const results = useMemo(() => {
25-
const values: ConversionResults = { conversions: [], highestConfidence: 0 };
26-
if (!selected || !currencyMap) {
27-
return values;
28-
}
29-
30-
try {
31-
for (const currency of currencies.filter((c) => c !== selected)) {
32-
const conversion = convert(selected, currency, currencyMap);
33-
34-
if (conversion.rate == null) {
35-
continue;
36-
}
37-
38-
if (conversion.confidence && conversion.confidence > values.highestConfidence) {
39-
values.highestConfidence = conversion.confidence;
40-
}
41-
42-
values.conversions.push({
43-
currency,
44-
calculation: value ? parseFloat(value) * conversion.rate : 0,
45-
confidence: conversion.confidence ?? 0
46-
});
47-
}
48-
} catch (e) {
49-
console.error(e);
50-
}
51-
52-
return values;
53-
}, [selected, value, currencyMap]);
54-
5519
if (currencyMap == null) {
5620
return (
5721
<div className='flex-1'>
@@ -72,20 +36,7 @@ export function CalculationView() {
7236
<p>Please select a currency</p>
7337
)}
7438

75-
<ErrorBoundary>
76-
<div className='flex flex-col gap-2'>
77-
{selected && <CalculationResults primary={selected} results={results} />}
78-
79-
<div className='flex flex-col gap-2'>
80-
<ColorInfo />
81-
82-
<p className='flex items-center gap-1 text-primary-darker italic text-xs'>
83-
<DatabaseBackup className='w-4 h-4' /> Last Updated:{" "}
84-
{new Date(currencyMap.meta.createdAt).toLocaleString()}
85-
</p>
86-
</div>
87-
</div>
88-
</ErrorBoundary>
39+
<ErrorBoundary>{selected && <CalculationResults selected={selected} value={value} />}</ErrorBoundary>
8940
</div>
9041
</div>
9142
</div>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useCurrencyMapData } from "../../hooks/useCurrencyMap";
2+
import { calculateConfidenceHue } from "./utils/calculateConfidenceHue";
3+
4+
export function ConfidenceColor({ confidence, highestConfidence }: { confidence: number; highestConfidence: number }) {
5+
const currencyMap = useCurrencyMapData()!;
6+
7+
return (
8+
<div
9+
className='flex items-center justify-center min-w-[3px] max-w-[2px] min-h-[20px] overflow-hidden text-transparent font-[FontinBold] text-nowrap text-xs select-none transition-all hover:min-w-[104px] hover:text-white hover:px-1'
10+
style={{
11+
backgroundColor: `hsl(${calculateConfidenceHue(
12+
confidence,
13+
highestConfidence,
14+
currencyMap.meta.createdAt
15+
)}, 70%, 50%)`
16+
}}>
17+
<span>Confidence: {confidence}%</span>
18+
</div>
19+
);
20+
}

src/components/CalculationView/Informational/ColorInfo.tsx

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { AccordionItem } from "@radix-ui/react-accordion";
2-
import { Accordion, AccordionContent, AccordionTrigger } from "../../shadcn/Accordion";
3-
import { useStorage } from "../../../hooks/useStorage";
41
import { MessageSquareWarning } from "lucide-react";
2+
3+
import { useStorage } from "../../../hooks/useStorage";
4+
import { useCurrencyMapData } from "../../../hooks/useCurrencyMap";
5+
6+
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../../shadcn/Accordion";
57
import { Button } from "../../shadcn/Button";
68

79
export function ColorInfo() {
10+
const currencyMap = useCurrencyMapData();
11+
812
const {
913
setPreferences,
1014
preferences: { hide }
@@ -28,21 +32,24 @@ export function ColorInfo() {
2832
<AccordionContent className='max-w-[400px] flex flex-col gap-2 items-start'>
2933
<p>
3034
The colors you see next to the price values represent <span className='underline'>confidence score</span> of
31-
that calculation. There may not be enough examples of a currency exchange in the data pool to{" "}
32-
<span className='font-[FontinItalic]'>confidently</span> suggest a conversion value.{" "}
33-
<span className='hidden md:inline'>You can hover over the color to see the exact score.</span>
35+
that calculation.
3436
</p>
35-
<div>
36-
<p>
37-
So, when the color is closer to <span className='bg-red-700 px-1 text-white'>red</span>, it probably does
38-
not represent real-world conversion rates.
39-
</p>
40-
<p>
41-
On the other hand, if you see the <span className='underline'>confidency score</span> leaning toward{" "}
42-
<span className='bg-green-700 px-1 text-white'>green</span>, that means, based on the amount of data we
43-
gathered for that particular exchange, we are confident about the result of that calculation.
44-
</p>
45-
</div>
37+
<p>
38+
The score is influenced by the <span className='underline'>amount</span> of data available for that
39+
particular exchange and the{" "}
40+
<span
41+
className='underline'
42+
title={currencyMap ? new Date(currencyMap.meta.createdAt).toLocaleString() : ""}>
43+
time
44+
</span>{" "}
45+
when that exchange was recorded.
46+
</p>
47+
<p>
48+
When the color is closer to <span className='bg-red-700 px-1 text-white'>red</span>, it probably does not
49+
represent real-world exchange rates.
50+
</p>
51+
52+
<p className='hidden md:inline'>You can also hover over the color indicator to see the exact score.</p>
4653
<Button
4754
variant='outline'
4855
onClick={() => setPreferences((prev) => ({ hide: { ...prev.hide, colorInfo: true } }))}>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Pin, PinOff } from "lucide-react";
2+
import clsx from "clsx";
3+
4+
import { useStorage } from "../../hooks/useStorage";
5+
6+
export function PinButton({ primary, secondary }: { primary: CurrencyKey; secondary: CurrencyKey }) {
7+
const {
8+
setPreferences,
9+
preferences: { pinned }
10+
} = useStorage();
11+
12+
const isCurrentlyPinned = pinned?.primary === primary && pinned?.secondary === secondary;
13+
14+
return (
15+
<button
16+
title={isCurrentlyPinned ? "Unpin" : "Pin to top"}
17+
type='button'
18+
className={clsx(
19+
"hover:text-primary-main transition-colors",
20+
isCurrentlyPinned ? "text-primary-main" : "text-primary-dark"
21+
)}
22+
onClick={() => {
23+
setPreferences(
24+
isCurrentlyPinned
25+
? { pinned: null }
26+
: {
27+
pinned: {
28+
primary: primary,
29+
secondary: secondary
30+
}
31+
}
32+
);
33+
}}>
34+
{isCurrentlyPinned ? <PinOff className='w-4 h-4' /> : <Pin className='w-4 h-4' />}
35+
</button>
36+
);
37+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { differenceInHours } from "date-fns";
2+
3+
const maxStaleHours = 24;
4+
5+
export function calculateConfidenceHue(confidence: number, highestConfidence: number, recordedAt: string) {
6+
const baseScore = confidence / highestConfidence;
7+
8+
const hoursElapsed = differenceInHours(new Date(), recordedAt);
9+
10+
// 1 = fully stale, 0 = fresh
11+
const staleFactor = Math.min(hoursElapsed / maxStaleHours, 1);
12+
13+
const adjustedConfidence = baseScore * (1 - staleFactor);
14+
15+
// 0 = red, 120 = green
16+
return adjustedConfidence * 120;
17+
}

0 commit comments

Comments
 (0)