├── public ├── og-image.png ├── icons │ ├── icon-192.png │ └── icon-512.png ├── dashboard-screenshot_dark.webp ├── dashboard-screenshot_light.webp ├── favicon.svg ├── window.svg ├── file.svg └── globe.svg ├── postcss.config.mjs ├── src ├── app │ ├── demo │ │ └── page.tsx │ ├── api │ │ ├── auth │ │ │ └── [...all] │ │ │ │ └── route.ts │ │ ├── price-history │ │ │ └── route.ts │ │ ├── exchange │ │ │ └── route.ts │ │ ├── isin-search │ │ │ └── route.ts │ │ ├── user-config │ │ │ └── route.ts │ │ └── raw-data │ │ │ └── route.ts │ ├── data-protection │ │ └── page.tsx │ ├── impressum │ │ └── page.tsx │ ├── (app) │ │ ├── (dashboard) │ │ │ ├── performance │ │ │ │ └── page.tsx │ │ │ ├── assets │ │ │ │ ├── page.tsx │ │ │ │ └── [isin] │ │ │ │ │ └── page.tsx │ │ │ ├── dashboard │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── layout.tsx │ │ └── data │ │ │ └── page.tsx │ ├── manifest.ts │ ├── globals.css │ ├── page.tsx │ └── layout.tsx ├── db │ ├── index.ts │ └── schema │ │ ├── user-config.ts │ │ ├── raw-data.ts │ │ └── auth.ts ├── lib │ ├── auth-client.ts │ ├── cache.ts │ ├── user-config-schema.ts │ ├── env.ts │ ├── auth.ts │ ├── raw-data-schema.ts │ ├── storage-adapter.ts │ ├── raw-data-fetch.ts │ ├── user-config-fetch.ts │ └── raw-data-server-storage-adapter.ts ├── mdx-components.tsx ├── features │ ├── dashboard │ │ ├── utils │ │ │ ├── extract-isins.ts │ │ │ ├── transaction-filter.ts │ │ │ ├── date-parse.ts │ │ │ ├── date-values.ts │ │ │ ├── price-history.ts │ │ │ ├── remove-known-symbol-wrappers.ts │ │ │ ├── euro-price-conversion.ts │ │ │ └── demo-data.tsx │ │ ├── types │ │ │ ├── price-history.ts │ │ │ ├── parsed-transactions.ts │ │ │ ├── depot-item.ts │ │ │ ├── asset.ts │ │ │ ├── raw-transaction-data-set.ts │ │ │ ├── account-transaction.ts │ │ │ ├── depot-transaction.ts │ │ │ └── yahoo-finance-schemas.ts │ │ ├── server │ │ │ ├── fetch-ticker-symbol.ts │ │ │ ├── search-symbol.ts │ │ │ └── fetch-price-history.ts │ │ ├── components │ │ │ ├── show-values-toggle.tsx │ │ │ ├── assets-grid.tsx │ │ │ ├── value-typography.tsx │ │ │ ├── depot-progress-bar.tsx │ │ │ ├── transaction-data-card.tsx │ │ │ ├── depot-provider-wrapper.tsx │ │ │ ├── sector-icons.tsx │ │ │ ├── depot-chart.tsx │ │ │ ├── csv-upload.tsx │ │ │ ├── pie-chart-switcher.tsx │ │ │ ├── price-history-chart.tsx │ │ │ ├── portfolio-overview.tsx │ │ │ ├── asset-history.tsx │ │ │ ├── growth-chart.tsx │ │ │ └── performance-chart.tsx │ │ ├── hooks │ │ │ ├── use-price-history.tsx │ │ │ ├── use-show-values.tsx │ │ │ ├── use-conversion-rates.tsx │ │ │ ├── use-ticker-data.tsx │ │ │ ├── use-depot.tsx │ │ │ ├── use-depot-item-details.tsx │ │ │ └── use-assets-calc.tsx │ │ ├── logic │ │ │ ├── anonymize-transactions.ts │ │ │ └── transaction-parsing.ts │ │ └── config │ │ │ └── nav-items.tsx │ ├── auth │ │ ├── components │ │ │ ├── sign-in.tsx │ │ │ ├── auth-switch.tsx │ │ │ ├── profile.tsx │ │ │ └── auth-dialog.tsx │ │ └── hooks │ │ │ └── use-auth-dialog.tsx │ └── landing │ │ └── components │ │ ├── landing-page-content.tsx │ │ └── faq.tsx ├── hooks │ ├── use-client-only.tsx │ ├── use-github-stars.tsx │ ├── use-encryption-key.tsx │ └── use-user-config.tsx ├── markdown │ ├── mdx-layout.tsx │ ├── impressum.mdx │ └── data-protection.mdx ├── components │ ├── cta-button.tsx │ ├── preserve-search-params-link.tsx │ ├── info-box.tsx │ ├── color-mode-toggle.tsx │ ├── repo-button.tsx │ ├── footer.tsx │ ├── client-wrapper.tsx │ ├── encryption-key-manager.tsx │ └── header.tsx └── theme.ts ├── Dockerfile.migrate ├── TODO.txt ├── .env.example ├── drizzle.config.ts ├── drizzle ├── 0001_user-config.sql ├── meta │ └── _journal.json ├── 0002_data-persist.sql └── 0000_faithful_piledriver.sql ├── next.config.ts ├── .gitignore ├── tailwind.config.js ├── tsconfig.json ├── README.md ├── docker-compose.yml ├── package.json └── Dockerfile /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhigatzberger/flatex-analyzer/HEAD/public/og-image.png -------------------------------------------------------------------------------- /public/icons/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhigatzberger/flatex-analyzer/HEAD/public/icons/icon-192.png -------------------------------------------------------------------------------- /public/icons/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhigatzberger/flatex-analyzer/HEAD/public/icons/icon-512.png -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/dashboard-screenshot_dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhigatzberger/flatex-analyzer/HEAD/public/dashboard-screenshot_dark.webp -------------------------------------------------------------------------------- /public/dashboard-screenshot_light.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhigatzberger/flatex-analyzer/HEAD/public/dashboard-screenshot_light.webp -------------------------------------------------------------------------------- /src/app/demo/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default function DemoPage() { 4 | redirect("/dashboard?demo=true"); 5 | } -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { drizzle } from "drizzle-orm/node-postgres"; 3 | 4 | export const db = drizzle(process.env.DATABASE_URL!); -------------------------------------------------------------------------------- /src/lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | export const { signIn, signUp, signOut, useSession, deleteUser } = createAuthClient() 3 | -------------------------------------------------------------------------------- /src/lib/cache.ts: -------------------------------------------------------------------------------- 1 | import { LRUCache } from 'lru-cache' 2 | 3 | export const cache = new LRUCache({ 4 | max: 100, // max number of items in cache 5 | ttl: 1000 * 60 * 60 * 10, // 10h 6 | }); -------------------------------------------------------------------------------- /src/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import type { MDXComponents } from "mdx/types"; 2 | 3 | export function useMDXComponents(components: MDXComponents): MDXComponents { 4 | return { 5 | ...components, 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; // path to your auth file 2 | import { toNextJsHandler } from "better-auth/next-js"; 3 | 4 | export const { POST, GET } = toNextJsHandler(auth); -------------------------------------------------------------------------------- /Dockerfile.migrate: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | CMD ["npx", "drizzle-kit", "push", "--config=drizzle.config.ts"] 12 | -------------------------------------------------------------------------------- /src/features/dashboard/utils/extract-isins.ts: -------------------------------------------------------------------------------- 1 | export function extractISINs(text: string): string[] { 2 | const isinRegex = /\b[A-Z]{2}[A-Z0-9]{9}[0-9]\b/g; 3 | const matches = text.match(isinRegex); 4 | return matches ?? []; 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | - move data persist to route 2 | - encrypt fr fr 3 | - update ui 4 | - data 5 | - bento 6 | - block server persistence when logged out 7 | - header 8 | - sidebar on mobile 9 | - test on staging / mobile 10 | - set up staging environment -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | YAHOO_FINANCE_WRAPPER_URL=http://yf-wrapper:5000/ 2 | 3 | FRANKFURTER_API_URL=https://api.frankfurter.app/ 4 | 5 | DATABASE_URL=postgres://postgres:postgres@pg-data:5432/mydb 6 | 7 | BETTER_AUTH_SECRET= 8 | BETTER_AUTH_URL=http://localhost:3000 9 | -------------------------------------------------------------------------------- /src/hooks/use-client-only.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useClientOnly() { 4 | const [isClient, setIsClient] = useState(false); 5 | 6 | useEffect(() => { 7 | setIsClient(true); 8 | }, []); 9 | return isClient; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/data-protection/page.tsx: -------------------------------------------------------------------------------- 1 | import MdxLayout from "@/markdown/mdx-layout"; 2 | import MDXContent from "@/markdown/data-protection.mdx"; 3 | 4 | export default function Legal() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/impressum/page.tsx: -------------------------------------------------------------------------------- 1 | import MdxLayout from "@/markdown/mdx-layout"; 2 | import MDXContent from "@/markdown/impressum.mdx"; 3 | 4 | export default function Legal() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/features/dashboard/types/price-history.ts: -------------------------------------------------------------------------------- 1 | export interface PriceHistoryResponse { 2 | dates: string[]; 3 | prices: Record; 4 | } 5 | 6 | export interface UsePriceHistoryParams { 7 | start: string; // YYYY-MM-DD 8 | end: string; // YYYY-MM-DD 9 | tickers: string[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/features/dashboard/types/parsed-transactions.ts: -------------------------------------------------------------------------------- 1 | import { ParsedAccountTransaction } from "./account-transaction"; 2 | import { ParsedDepotTransaction } from "./depot-transaction"; 3 | 4 | export interface ParsedTransactions { 5 | depotTransactions: ParsedDepotTransaction[]; 6 | accountTransactions: ParsedAccountTransaction[]; 7 | } -------------------------------------------------------------------------------- /src/lib/user-config-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export enum DataPersistenceMode { 4 | NONE = "none", 5 | LOCAL = "local", 6 | SERVER = "server", 7 | } 8 | 9 | export const UserConfigSchema = z.object({ 10 | dataPersistenceMode: z.nativeEnum(DataPersistenceMode).default(DataPersistenceMode.NONE), 11 | }); 12 | -------------------------------------------------------------------------------- /src/features/dashboard/utils/transaction-filter.ts: -------------------------------------------------------------------------------- 1 | import { ParsedAccountTransaction } from "../types/account-transaction"; 2 | 3 | export function isInOutGoingTransaction(accountTransaction: ParsedAccountTransaction): boolean { 4 | // Check if the transaction is an outgoing transaction 5 | return accountTransaction["IBAN / Kontonummer"] !== ""; 6 | } -------------------------------------------------------------------------------- /src/app/(app)/(dashboard)/performance/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import PerformanceChart from "@/features/dashboard/components/performance-chart"; 4 | import { Suspense } from "react"; 5 | 6 | export default function PerformancePage() { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | if (process.env.NODE_ENV !== "production") { 4 | require("dotenv").config(); 5 | } 6 | 7 | export default defineConfig({ 8 | out: "./drizzle", 9 | schema: "./src/db/schema", 10 | dialect: "postgresql", 11 | dbCredentials: { 12 | url: process.env.DATABASE_URL!, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/features/dashboard/types/depot-item.ts: -------------------------------------------------------------------------------- 1 | import { ParsedAccountTransaction } from "./account-transaction"; 2 | import { ParsedDepotTransaction } from "./depot-transaction"; 3 | 4 | export interface DepotItem { 5 | isin: string; 6 | name: string; 7 | relatedIsins: string[]; 8 | depotTransactions: ParsedDepotTransaction[]; 9 | accountTransactions: ParsedAccountTransaction[]; 10 | } 11 | -------------------------------------------------------------------------------- /drizzle/0001_user-config.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "user_config" ( 2 | "user_id" text PRIMARY KEY NOT NULL, 3 | "settings" json NOT NULL, 4 | "created_at" timestamp NOT NULL, 5 | "updated_at" timestamp NOT NULL 6 | ); 7 | --> statement-breakpoint 8 | ALTER TABLE "user_config" ADD CONSTRAINT "user_config_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; -------------------------------------------------------------------------------- /src/markdown/mdx-layout.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "@mui/material"; 2 | 3 | export default function MdxLayout({ children }: { children: React.ReactNode }) { 4 | // Create any shared layout or styles here 5 | return ( 6 |
7 |
{children}
8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/features/dashboard/types/asset.ts: -------------------------------------------------------------------------------- 1 | import { DepotItemDetails } from "../hooks/use-depot-item-details"; 2 | import { FullTickerData } from "./yahoo-finance-schemas"; 3 | import { DepotItem } from "./depot-item"; 4 | 5 | export interface Asset extends DepotItem { 6 | details: DepotItemDetails; 7 | tickerData: FullTickerData | null; 8 | currentEuroPrice: number | null; 9 | currentPositionValue: number | null; 10 | priceHistory?: { date: string; price: number }[]; 11 | } -------------------------------------------------------------------------------- /src/features/dashboard/utils/date-parse.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | const customParseFormat = require("dayjs/plugin/customParseFormat"); 3 | dayjs.extend(customParseFormat); 4 | 5 | export const LOCAL_FORMAT = "D.M.YYYY"; 6 | 7 | export const ISO_FORMAT = "YYYY-MM-DD"; 8 | 9 | export function parseDate(dateString: string): Date | null { 10 | const parsedDate = dayjs(dateString, LOCAL_FORMAT); 11 | return parsedDate.isValid() ? parsedDate.toDate() : null; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/cta-button.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowForward } from "@mui/icons-material"; 2 | import { Button } from "@mui/material"; 3 | import Link from "next/link"; 4 | 5 | export function CtaButton() { 6 | return ( 7 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/features/auth/components/sign-in.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@mui/material"; 4 | 5 | interface Props extends React.ComponentProps { 6 | onSignIn: () => void; 7 | } 8 | 9 | const SignIn = ({ onSignIn, ...props }: Props) => { 10 | return ( 11 | 20 | ); 21 | }; 22 | 23 | export default SignIn; 24 | -------------------------------------------------------------------------------- /src/app/(app)/(dashboard)/assets/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AssetsGrid } from "@/features/dashboard/components/assets-grid"; 4 | import { useDepot } from "@/features/dashboard/hooks/use-depot"; 5 | 6 | export default function AssetsPage() { 7 | const { assets } = useDepot(); 8 | const sortedItems = [...assets].sort((a, b) => { 9 | const valueA = a.currentPositionValue || 0; 10 | const valueB = b.currentPositionValue || 0; 11 | return valueB - valueA; 12 | }); 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /src/features/dashboard/server/fetch-ticker-symbol.ts: -------------------------------------------------------------------------------- 1 | import { getEnv } from "../../../lib/env"; 2 | import { FullTickerData } from "../types/yahoo-finance-schemas"; 3 | 4 | export async function fetchTickerData(ticker: string): Promise { 5 | const url = `${getEnv().YAHOO_FINANCE_WRAPPER_URL}stock/${ticker}`; 6 | 7 | const response = await fetch(url); 8 | if (!response.ok) { 9 | throw new Error(`Failed to fetch ticker data for ${ticker}`); 10 | } 11 | 12 | const data = await response.json(); 13 | 14 | return data; 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/env.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const envSchema = z.object({ 4 | YAHOO_FINANCE_WRAPPER_URL: z.string().url(), 5 | FRANKFURTER_API_URL: z.string().url(), 6 | }); 7 | 8 | let cachedEnv: z.infer | null = null; 9 | 10 | export function getEnv(): z.infer { 11 | if (!cachedEnv) { 12 | cachedEnv = envSchema.parse({ 13 | YAHOO_FINANCE_WRAPPER_URL: process.env.YAHOO_FINANCE_WRAPPER_URL, 14 | FRANKFURTER_API_URL: process.env.FRANKFURTER_API_URL, 15 | }); 16 | } 17 | return cachedEnv; 18 | } 19 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import createMDX from "@next/mdx"; 2 | import type { NextConfig } from "next"; 3 | 4 | const nextConfig: NextConfig = { 5 | devIndicators: false, 6 | output: "standalone", 7 | // Configure `pageExtensions` to include markdown and MDX files 8 | pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"], 9 | // Optionally, add any other Next.js config below 10 | }; 11 | 12 | const withMDX = createMDX({ 13 | // Add markdown plugins here, as desired 14 | }); 15 | 16 | // Merge MDX config with Next.js config 17 | export default withMDX(nextConfig); 18 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth"; 2 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 3 | import { db } from "@/db"; // your drizzle instance 4 | import * as authSchema from "@/db/schema/auth"; // your auth schema 5 | 6 | export const auth = betterAuth({ 7 | database: drizzleAdapter(db, { 8 | provider: "pg", 9 | schema: { 10 | ...authSchema, 11 | }, 12 | }), 13 | user: { 14 | deleteUser: { 15 | enabled: true, 16 | }, 17 | }, 18 | emailAndPassword: { 19 | enabled: true, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/features/dashboard/utils/date-values.ts: -------------------------------------------------------------------------------- 1 | import { DateValue } from "../logic/analyze"; 2 | 3 | export function getLatestDateValue( 4 | sortedDateValues: DateValue[], 5 | date: Date 6 | ): DateValue | null { 7 | if (sortedDateValues.length === 0) return null; 8 | 9 | // Find the last entry that is before or on the given date 10 | for (let i = sortedDateValues.length - 1; i >= 0; i--) { 11 | if (sortedDateValues[i].date <= date) { 12 | return sortedDateValues[i]; 13 | } 14 | } 15 | 16 | // If no entry found, return null 17 | return null; 18 | } 19 | -------------------------------------------------------------------------------- /src/features/dashboard/components/show-values-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography, Switch } from "@mui/material"; 2 | import { useShowValues } from "../hooks/use-show-values"; 3 | 4 | export function ShowValuesToggle() { 5 | const { showValues, setShowValues } = useShowValues(); 6 | return ( 7 | 8 | 9 | Beträge anzeigen 10 | 11 | setShowValues(e.target.checked)} /> 12 | 13 | ); 14 | } -------------------------------------------------------------------------------- /src/features/dashboard/utils/price-history.ts: -------------------------------------------------------------------------------- 1 | import { DateValue } from "../logic/analyze"; 2 | import { PriceHistoryResponse } from "../types/price-history"; 3 | 4 | export function priceHistoryToDateValues( 5 | priceHistory: PriceHistoryResponse, 6 | ticker: string 7 | ): DateValue[] { 8 | const { dates, prices } = priceHistory; 9 | const priceArr = prices[ticker]; 10 | 11 | if (!priceArr) throw new Error(`Ticker ${ticker} not found in price history`); 12 | 13 | return dates.map((dateStr, i) => ({ 14 | date: new Date(dateStr), 15 | value: priceArr[i], 16 | })); 17 | } 18 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1750103114839, 9 | "tag": "0000_faithful_piledriver", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "7", 15 | "when": 1750266091397, 16 | "tag": "0001_user-config", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "7", 22 | "when": 1750343136897, 23 | "tag": "0002_data-persist", 24 | "breakpoints": true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /src/app/(app)/(dashboard)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { PortfolioOverview } from "@/features/dashboard/components/portfolio-overview"; 4 | import { useDepot } from "@/features/dashboard/hooks/use-depot"; 5 | import { Suspense } from "react"; 6 | 7 | export default function DashboardPage() { 8 | const { assets, accountTransactions, progress } = useDepot(); 9 | return ( 10 | 11 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/features/dashboard/components/assets-grid.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from "@mui/material"; 2 | import { Asset } from "../types/asset"; 3 | import { DepotItemCard } from "./depot-item-card"; 4 | 5 | export function AssetsGrid({ 6 | assets, 7 | baseUrl = "/assets", 8 | }: { 9 | assets: Asset[]; 10 | baseUrl?: string; 11 | }) { 12 | return ( 13 | 14 | {assets.map((item) => ( 15 | 16 | 17 | 18 | ))} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/raw-data-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const baseRawDataSchema = z.object({ 4 | id: z.string().min(1), 5 | encryptedData: z.string().min(1), 6 | fileName: z.string().min(1), 7 | timestamp: z.coerce.date(), // allows strings, converts to Date 8 | }); 9 | 10 | export const rawDataPayloadSchema = z.object({ 11 | depot: z.array(baseRawDataSchema), 12 | account: z.array(baseRawDataSchema), 13 | }); 14 | 15 | // For TypeScript usage 16 | export type BaseRawData = z.infer; 17 | export type RawDataPayload = z.infer; 18 | -------------------------------------------------------------------------------- /src/db/schema/user-config.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, text, boolean, json, timestamp } from "drizzle-orm/pg-core"; 2 | import { user } from "./auth"; // adjust import if needed 3 | 4 | export const userConfig = pgTable("user_config", { 5 | userId: text("user_id") 6 | .primaryKey() 7 | .references(() => user.id, { onDelete: "cascade" }), 8 | 9 | settings: json("settings").notNull(), // type this in app code with Zod/TS types 10 | 11 | createdAt: timestamp("created_at") 12 | .$defaultFn(() => new Date()) 13 | .notNull(), 14 | updatedAt: timestamp("updated_at") 15 | .$defaultFn(() => new Date()) 16 | .notNull(), 17 | }); -------------------------------------------------------------------------------- /src/features/dashboard/types/raw-transaction-data-set.ts: -------------------------------------------------------------------------------- 1 | import { AccountTransaction } from "./account-transaction"; 2 | import { DepotTransaction } from "./depot-transaction"; 3 | 4 | export interface RawDataSet { 5 | id: string; 6 | data: T[]; 7 | fileName: string; 8 | timestamp: Date; 9 | valid: boolean; 10 | } 11 | 12 | export interface RawAccountTransactionDataSet extends RawDataSet {} 13 | 14 | export interface RawDepotTransactionDataSet extends RawDataSet {} 15 | 16 | export type RawDataState = { 17 | depot: RawDepotTransactionDataSet[]; 18 | account: RawAccountTransactionDataSet[]; 19 | }; 20 | -------------------------------------------------------------------------------- /src/features/dashboard/utils/remove-known-symbol-wrappers.ts: -------------------------------------------------------------------------------- 1 | const KNOWN_SYMBOL_WRAPPERS = [ 2 | "CL.SN" 3 | ]; 4 | 5 | export function removeKnownSymbolWrappers(symbol: string): string { 6 | for (const wrapper of KNOWN_SYMBOL_WRAPPERS) { 7 | const regex = new RegExp(`${wrapper}$`); 8 | if (regex.test(symbol)) { 9 | return symbol.replace(regex, ''); 10 | } 11 | } 12 | return symbol; 13 | } 14 | 15 | 16 | const ISIN_REMAP = { 17 | "US02079K3059": "GOOGL" 18 | } 19 | 20 | export function hardCodedIsinRemap(symbol: string): string { 21 | if(ISIN_REMAP[symbol] === undefined) return symbol; 22 | return ISIN_REMAP[symbol] || symbol; 23 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './app/**/*.{js,ts,jsx,tsx,mdx}', // App directory 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', // Fallback for pages dir 6 | './components/**/*.{js,ts,jsx,tsx}', // Components 7 | './content/**/*.{md,mdx}', // Markdown/MDX content folders 8 | ], 9 | theme: { 10 | extend: { 11 | typography: { 12 | DEFAULT: { 13 | css: { 14 | maxWidth: '100%', // Let prose content expand fully 15 | }, 16 | }, 17 | }, 18 | }, 19 | }, 20 | plugins: [require('@tailwindcss/typography')], 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | }, 7 | "target": "ES2017", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "noEmit": true, 13 | "incremental": true, 14 | "module": "esnext", 15 | "esModuleInterop": true, 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ] 25 | }, 26 | "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /src/features/dashboard/components/value-typography.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Typography, { TypographyProps } from "@mui/material/Typography"; 3 | import { useShowValues } from "../hooks/use-show-values"; 4 | 5 | type ValueTypographyProps = TypographyProps & { 6 | placeholder?: React.ReactNode; 7 | }; 8 | 9 | const ValueTypography: React.FC = ({ 10 | placeholder = "•••••", 11 | children, 12 | ...typographyProps 13 | }) => { 14 | const { showValues } = useShowValues(); 15 | 16 | return ( 17 | 18 | {showValues ? children : placeholder} 19 | 20 | ); 21 | }; 22 | 23 | export default ValueTypography; 24 | -------------------------------------------------------------------------------- /src/app/manifest.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next' 2 | 3 | export default function manifest(): MetadataRoute.Manifest { 4 | return { 5 | name: 'Flatex Dashboard', 6 | short_name: 'FlatexAnalyzer', 7 | description: 'Flatex Portfolio Statistik: Überblick über Dividenden, Verkäufe und Performance deines Flatex Depots', 8 | start_url: '/', 9 | display: 'standalone', 10 | background_color: '#ffffff', 11 | theme_color: '#1a1a1a', 12 | icons: [ 13 | { 14 | src: '/icons/icon-192.png', 15 | sizes: '192x192', 16 | type: 'image/png', 17 | }, 18 | { 19 | src: '/icons/icon-512.png', 20 | sizes: '512x512', 21 | type: 'image/png', 22 | }, 23 | ], 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/preserve-search-params-link.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useSearchParams } from "next/navigation"; 3 | import NextLink, { LinkProps } from "next/link"; 4 | import React from "react"; 5 | 6 | type Props = LinkProps & { 7 | children: React.ReactNode; 8 | }; 9 | 10 | export function PreserveSearchParamsLink({ href, children, ...props }: Props) { 11 | const searchParams = useSearchParams(); 12 | let mergedHref = href; 13 | 14 | if (typeof href === "string" && searchParams.size > 0) { 15 | mergedHref = href.includes("?") 16 | ? `${href}&${searchParams.toString()}` 17 | : `${href}?${searchParams.toString()}`; 18 | } 19 | 20 | return ( 21 | 22 | {children} 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/hooks/use-github-stars.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useGitHubStars = (repo: string) => { 4 | const [stars, setStars] = useState(null); 5 | 6 | useEffect(() => { 7 | const fetchStars = async () => { 8 | try { 9 | const res = await fetch(`https://api.github.com/repos/${repo}`); 10 | const data = await res.json(); 11 | if (data.stargazers_count !== undefined) { 12 | setStars(data.stargazers_count); 13 | } 14 | } catch (error) { 15 | console.error("Fehler beim Laden der GitHub-Stars:", error); 16 | } 17 | }; 18 | 19 | fetchStars(); 20 | }, [repo]); 21 | 22 | return stars; 23 | }; 24 | -------------------------------------------------------------------------------- /src/features/dashboard/server/search-symbol.ts: -------------------------------------------------------------------------------- 1 | import yahooFinance from "yahoo-finance2"; 2 | import { QuoteSearchSchema } from "../types/yahoo-finance-schemas"; 3 | import { hardCodedIsinRemap } from "../utils/remove-known-symbol-wrappers"; 4 | 5 | export async function searchSymbol(isin: string) { 6 | isin = hardCodedIsinRemap(isin); 7 | const searchResult = await yahooFinance.search(isin, { 8 | region: "US", 9 | }); 10 | console.log("Search result for ISIN:", isin, searchResult); 11 | const match = searchResult.quotes?.[0]; 12 | 13 | const parsed = QuoteSearchSchema.safeParse(match); 14 | if (!parsed.success) { 15 | console.error("Failed to parse search result", parsed.error); 16 | throw new Error("No valid quote found for ISIN"); 17 | } 18 | 19 | return parsed.data.symbol; 20 | } 21 | -------------------------------------------------------------------------------- /src/features/dashboard/utils/euro-price-conversion.ts: -------------------------------------------------------------------------------- 1 | export function convertToEuroPrice( 2 | price: number, 3 | currencies: Record, 4 | currency: string 5 | ): number { 6 | let normalizedPrice = price; 7 | let normalizedCurrency = currency.toUpperCase(); 8 | 9 | // Special case: GBp (British pence) → divide by 100 to convert to GBP 10 | if (currency === "GBp" || currency === "gbp") { 11 | normalizedPrice = price / 100; 12 | normalizedCurrency = "GBP"; 13 | } 14 | 15 | if (normalizedCurrency === "EUR") { 16 | return normalizedPrice; 17 | } 18 | 19 | const conversionRate = currencies[normalizedCurrency]; 20 | if (!conversionRate) { 21 | console.warn(`Conversion rate for ${currency} not found`); 22 | return 0; 23 | } 24 | 25 | return normalizedPrice / conversionRate; 26 | } 27 | -------------------------------------------------------------------------------- /src/features/dashboard/types/account-transaction.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export interface ParsedAccountTransaction { 4 | Buchtag: Date; 5 | Valuta: Date; 6 | "BIC / BLZ": string; 7 | "IBAN / Kontonummer": string; 8 | Buchungsinformationen: string; 9 | "TA-Nr.": string; 10 | Betrag: number; 11 | "": string; 12 | Auftraggeberkonto: string; 13 | Konto: string; 14 | } 15 | 16 | export const AccountTransactionSchema = z.object({ 17 | Buchtag: z.string(), 18 | Valuta: z.string(), 19 | "BIC / BLZ": z.string(), 20 | "IBAN / Kontonummer": z.string(), 21 | Buchungsinformationen: z.string(), 22 | "TA-Nr.": z.string(), 23 | Betrag: z.string(), 24 | "": z.string(), 25 | Auftraggeberkonto: z.string(), 26 | Konto: z.string(), 27 | }); 28 | 29 | export type AccountTransaction = z.infer; 30 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | } 7 | 8 | @theme inline { 9 | --color-background: var(--background); 10 | --color-foreground: var(--foreground); 11 | --font-sans: var(--font-geist-sans); 12 | --font-mono: var(--font-geist-mono); 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | background: var(--background); 24 | color: var(--foreground); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | 28 | /* Ensure the image element has position: relative */ 29 | .blur-gradient-bottom { 30 | -webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 80%); 31 | mask-image: linear-gradient(to bottom, black 40%, transparent 80%); 32 | } -------------------------------------------------------------------------------- /src/features/dashboard/types/depot-transaction.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { parseDate } from "../utils/date-parse"; 3 | 4 | export interface ParsedDepotTransaction { 5 | Nummer: string; 6 | Buchtag: Date; 7 | Valuta: Date; 8 | ISIN: string; 9 | Bezeichnung: string; 10 | Nominal: number; 11 | "": string; 12 | Buchungsinformationen: string; 13 | "TA-Nr.": string; 14 | Kurs: number; 15 | _1: string; 16 | Depot: string; 17 | } 18 | 19 | export const DepotTransactionSchema = z.object({ 20 | Nummer: z.string(), 21 | Buchtag: z.string(), 22 | Valuta: z.string(), 23 | ISIN: z.string(), 24 | Bezeichnung: z.string(), 25 | Nominal: z.string(), 26 | "": z.string(), 27 | Buchungsinformationen: z.string(), 28 | "TA-Nr.": z.string(), 29 | Kurs: z.string(), 30 | _1: z.string(), 31 | Depot: z.string(), 32 | }); 33 | 34 | export type DepotTransaction = z.infer; 35 | -------------------------------------------------------------------------------- /src/components/info-box.tsx: -------------------------------------------------------------------------------- 1 | // components/TransparentInfoBox.tsx 2 | import React from "react"; 3 | import { alpha, Box, Typography, useTheme } from "@mui/material"; 4 | import InfoIcon from "@mui/icons-material/Info"; 5 | 6 | export default function InfoBox({ text }: { text: string }) { 7 | 8 | const theme = useTheme(); 9 | const mainColor = alpha(theme.palette.primary.main, 0.1); 10 | const borderColor = alpha(theme.palette.primary.main, 0.5); 11 | return ( 12 | 22 | 23 | 24 | {text} 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { blue, orange } from "@mui/material/colors"; 3 | import { createTheme } from "@mui/material/styles"; 4 | 5 | const softShadow = '0px 4px 12px rgba(0, 0, 0, 0.1)'; // soft, diffused shadow 6 | 7 | const theme = createTheme({ 8 | shape: { 9 | borderRadius: 8, // or any value you like 10 | }, 11 | // @ts-ignore 12 | shadows: [ 13 | 'none', 14 | ...Array(24).fill(softShadow), // fill remaining shadows with softShadow 15 | ], 16 | colorSchemes: { 17 | light: { 18 | palette: { 19 | mode: "light", 20 | primary: { main: orange[800] }, 21 | secondary: { main: blue[600] }, 22 | }, 23 | }, 24 | dark: { 25 | palette: { 26 | mode: "dark", 27 | primary: { main: orange[400] }, 28 | secondary: { main: blue[300] }, 29 | }, 30 | }, 31 | }, 32 | typography: { 33 | fontFamily: "var(--font-roboto)", 34 | }, 35 | }); 36 | 37 | export default theme; 38 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Header } from "@/components/header"; 4 | import LandingPageContent from "../features/landing/components/landing-page-content"; 5 | import { Footer } from "@/components/footer"; 6 | import { useEffect } from 'react'; 7 | import { useRouter } from 'next/navigation'; 8 | import { useSession } from "@/lib/auth-client"; 9 | 10 | export default function LandingPage() { 11 | const { data: session, isPending: isLoading } = useSession(); 12 | const router = useRouter(); 13 | 14 | useEffect(() => { 15 | if (isLoading) return; 16 | 17 | const hasVisited = sessionStorage.getItem('visitedLanding'); 18 | 19 | if (session?.user && !hasVisited) { 20 | sessionStorage.setItem('visitedLanding', 'true'); 21 | router.replace('/dashboard'); 22 | } 23 | }, [session, isLoading, router]); 24 | 25 | return ( 26 | <> 27 |
28 | 29 |