Merge pull request 'feature/8-stats-bar' (#15) from feature/8-stats-bar into main

Reviewed-on: https://codeberg.org/lvl0/incr/pulls/15
This commit is contained in:
Jochen Timmermans 2025-07-13 01:27:48 +02:00
commit 2359e93d58
59 changed files with 1474 additions and 77 deletions

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Milestones;
use App\Http\Controllers\Controller;
use App\Models\Milestone;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
class MilestoneController extends Controller
{
public function store(Request $request): RedirectResponse
{
$request->validate([
'target' => 'required|integer|min:1',
'description' => 'required|string|max:255',
]);
Milestone::create([
'target' => $request->target,
'description' => $request->description,
]);
return back()->with('success', 'Milestone created successfully');
}
public function index(): JsonResponse
{
$milestones = Milestone::orderBy('target')->get();
return response()->json($milestones);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Pricing;
use App\Http\Controllers\Controller;
use App\Models\Pricing\AssetPrice;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PricingController extends Controller
{
public function current(): JsonResponse
{
$price = AssetPrice::current();
return response()->json([
'current_price' => $price,
]);
}
public function update(Request $request)
{
$validated = $request->validate([
'date' => 'required|date|before_or_equal:today',
'price' => 'required|numeric|min:0.0001',
]);
$assetPrice = AssetPrice::updatePrice($validated['date'], $validated['price']);
return back()->with('success', 'Asset price updated successfully!');
}
public function history(Request $request): JsonResponse
{
$limit = $request->get('limit', 30);
$history = AssetPrice::history($limit);
return response()->json($history);
}
public function forDate(Request $request, string $date): JsonResponse
{
$price = AssetPrice::forDate($date);
return response()->json([
'date' => $date,
'price' => $price,
]);
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use App\Models\Transactions\Purchase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
class PurchaseController extends Controller
{
public function index(): JsonResponse
{
$purchases = Purchase::orderBy('date', 'desc')->get();
return response()->json($purchases);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'date' => 'required|date|before_or_equal:today',
'shares' => 'required|numeric|min:0.000001',
'price_per_share' => 'required|numeric|min:0.01',
'total_cost' => 'required|numeric|min:0.01',
]);
// Verify calculation is correct
$calculatedTotal = $validated['shares'] * $validated['price_per_share'];
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
return back()->withErrors([
'total_cost' => 'Total cost does not match shares × price per share.'
]);
}
Purchase::create([
'date' => $validated['date'],
'shares' => $validated['shares'],
'price_per_share' => $validated['price_per_share'],
'total_cost' => $validated['total_cost'],
]);
return response()->json([
'success' => true,
'message' => 'Purchase added successfully!',
]);
}
public function summary()
{
$totalShares = Purchase::totalShares();
$totalInvestment = Purchase::totalInvestment();
$averageCost = Purchase::averageCostPerShare();
return response()->json([
'total_shares' => $totalShares,
'total_investment' => $totalInvestment,
'average_cost_per_share' => $averageCost,
]);
}
/**
* Remove the specified purchase.
*/
public function destroy(Purchase $purchase)
{
$purchase->delete();
return response()->json([
'success' => true,
'message' => 'Purchase deleted successfully!',
]);
}
}

21
app/Models/Milestone.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @method static create(array $array)
* @method static orderBy(string $string)
*/
class Milestone extends Model
{
protected $fillable = [
'target',
'description',
];
protected $casts = [
'target' => 'integer',
];
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Models\Pricing;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @method static latest(string $string)
* @method static where(string $string, string $string1, string $date)
* @method static updateOrCreate(string[] $array, float[] $array1)
* @method static orderBy(string $string, string $string1)
* @property Carbon $date
* @property float $price
*/
class AssetPrice extends Model
{
use HasFactory;
protected $fillable = [
'date',
'price',
];
protected $casts = [
'date' => 'date',
'price' => 'decimal:4',
];
public static function current(): ?float
{
$latestPrice = static::latest('date')->first();
return $latestPrice ? $latestPrice->price : null;
}
public static function forDate(string $date): ?float
{
$price = static::where('date', '<=', $date)
->orderBy('date', 'desc')
->first();
return $price ? $price->price : null;
}
public static function updatePrice(string $date, float $price): self
{
return static::updateOrCreate(
['date' => $date],
['price' => $price]
);
}
public static function history(int $limit = 30): Collection
{
return static::orderBy('date', 'desc')->limit($limit)->get();
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Models\Transactions;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Purchase extends Model
{
use HasFactory;
protected $fillable = [
'date',
'shares',
'price_per_share',
'total_cost',
];
protected $casts = [
'date' => 'date',
'shares' => 'decimal:6',
'price_per_share' => 'decimal:4',
'total_cost' => 'decimal:2',
];
/**
* Calculate total shares
*/
public static function totalShares(): float
{
return static::sum('shares');
}
/**
* Calculate total investment
*/
public static function totalInvestment(): float
{
return static::sum('total_cost');
}
/**
* Get average cost per share
*/
public static function averageCostPerShare(): float
{
$totalShares = static::totalShares();
$totalCost = static::totalInvestment();
return $totalShares > 0 ? $totalCost / $totalShares : 0;
}
}

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('purchases', function (Blueprint $table) {
$table->id();
$table->date('date');
$table->decimal('shares', 12, 6); // Supports fractional shares
$table->decimal('price_per_share', 8, 4); // Price in euros
$table->decimal('total_cost', 12, 2); // Total cost in euros
$table->timestamps();
$table->index('date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('purchases');
}
};

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('asset_prices', function (Blueprint $table) {
$table->id();
$table->date('date');
$table->decimal('price', 10, 4);
$table->timestamps();
$table->unique('date');
$table->index('date');
});
}
public function down(): void
{
Schema::dropIfExists('asset_prices');
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('milestones', function (Blueprint $table) {
$table->id();
$table->integer('target');
$table->string('description');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('milestones');
}
};

BIN
public/fonts/7segment.woff Normal file

Binary file not shown.

View file

@ -5,12 +5,26 @@
@source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@font-face {
font-family: '7Segment';
src: url('/fonts/7segment.woff') format('woff');
font-weight: normal;
font-style: normal;
}
.font-digital {
font-family: '7Segment', monospace;
}
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono-display:
'Major Mono Display', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);

View file

@ -1,19 +1,19 @@
import { Breadcrumbs } from '@/components/breadcrumbs';
import { Breadcrumbs } from '@/components/Display/Breadcrumbs';
import { Icon } from '@/components/icon';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/navigation-menu';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/NavigationMenu';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { UserMenuContent } from '@/components/user-menu-content';
import { UserMenuContent } from '@/components/Settings/UserMenuContent';
import { useInitials } from '@/hooks/use-initials';
import { cn } from '@/lib/utils';
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
import AppLogo from './app-logo';
import AppLogoIcon from './app-logo-icon';
import AppLogo from './AppLogo';
import AppLogoIcon from './AppLogoIcon';
const mainNavItems: NavItem[] = [
{

View file

@ -1,4 +1,4 @@
import AppLogoIcon from './app-logo-icon';
import AppLogoIcon from './AppLogoIcon';
export default function AppLogo() {
return (

View file

@ -1,11 +1,11 @@
import { NavFooter } from '@/components/nav-footer';
import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user';
import { NavFooter } from '@/components/Display/NavFooter';
import { NavMain } from '@/components/Display/NavMain';
import { NavUser } from '@/components/Display/NavUser';
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
import { type NavItem } from '@/types';
import { Link } from '@inertiajs/react';
import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
import AppLogo from './app-logo';
import AppLogo from './AppLogo';
const mainNavItems: NavItem[] = [
{

View file

@ -1,4 +1,4 @@
import { Breadcrumbs } from '@/components/breadcrumbs';
import { Breadcrumbs } from '@/components/Display/Breadcrumbs';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';

View file

@ -0,0 +1,78 @@
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
import { cn } from '@/lib/utils';
import { X } from 'lucide-react';
interface InlineFormProps {
type: 'purchase' | 'milestone' | 'price' | null;
onClose: () => void;
onPurchaseSuccess?: () => void;
onMilestoneSuccess?: () => void;
onPriceSuccess?: () => void;
className?: string;
}
export default function InlineForm({
type,
onClose,
onPurchaseSuccess,
onMilestoneSuccess,
onPriceSuccess,
className
}: InlineFormProps) {
if (!type) return null;
const title = type === 'purchase' ? 'ADD PURCHASE' : type === 'milestone' ? 'ADD MILESTONE' : 'UPDATE PRICE';
return (
<div
className={cn(
"bg-black border-4 border-gray-800 rounded-lg",
"shadow-2xl shadow-red-500/20",
"p-6",
className
)}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-red-500 font-mono tracking-wide text-lg">
{title}
</h2>
<button
onClick={onClose}
className="text-red-400 hover:text-red-300 transition-colors p-1"
aria-label="Close form"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form Content */}
<div className="flex justify-center">
{type === 'purchase' ? (
<AddPurchaseForm
onSuccess={() => {
if (onPurchaseSuccess) onPurchaseSuccess();
onClose();
}}
/>
) : type === 'milestone' ? (
<AddMilestoneForm
onSuccess={() => {
if (onMilestoneSuccess) onMilestoneSuccess();
onClose();
}}
/>
) : (
<UpdatePriceForm
onSuccess={() => {
if (onPriceSuccess) onPriceSuccess();
onClose();
}}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
interface LedDisplayProps {
value: number;
className?: string;
animate?: boolean;
onClick?: () => void;
}
export default function LedDisplay({
value,
className,
onClick
}: LedDisplayProps) {
const [displayValue, setDisplayValue] = useState(0);
// Animate number changes
useEffect(() => {
setDisplayValue(value);
return;
}, [value]);
// Format number with zero-padding for consistent width
const formatValue = (value: number) => {
// Always pad to 5 digits for consistent display width
const integerPart = Math.floor(value);
return integerPart.toString().padStart(5, '0');
};
const formattedValue = formatValue(displayValue);
return (
<div
className={cn(
"w-full text-center select-none cursor-pointer",
"bg-black text-red-500",
"px-8 py-12 transition-all duration-300",
className
)}
onClick={onClick}
>
<div className="relative w-full flex items-center justify-center">
<div className={cn(
"relative z-10",
"text-[8rem] md:text-[12rem] lg:text-[16rem]",
"font-digital font-normal",
"text-red-500",
"drop-shadow-[0_0_10px_rgba(239,68,68,0.8)]",
"filter brightness-110",
"leading-none",
"transition-all duration-300"
)}
style={{ letterSpacing: '0.15em' }}>
{formattedValue}
</div>
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar';
import { UserInfo } from '@/components/user-info';
import { UserMenuContent } from '@/components/user-menu-content';
import { UserInfo } from '@/components/Settings/UserInfo';
import { UserMenuContent } from '@/components/Settings/UserMenuContent';
import { useIsMobile } from '@/hooks/use-mobile';
import { type SharedData } from '@/types';
import { usePage } from '@inertiajs/react';

View file

@ -0,0 +1,69 @@
import { cn } from '@/lib/utils';
interface Milestone {
target: number;
description: string;
created_at: string;
}
interface ProgressBarProps {
currentShares: number;
milestones: Milestone[];
selectedMilestoneIndex?: number;
className?: string;
onClick?: () => void;
}
export default function ProgressBar({
currentShares,
milestones,
selectedMilestoneIndex = 0,
className,
onClick
}: ProgressBarProps) {
// Get the selected milestone for progress calculation
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
? milestones[selectedMilestoneIndex]
: null;
// Calculate progress percentage
const progressPercentage = selectedMilestone
? Math.min((currentShares / selectedMilestone.target) * 100, 100)
: 0;
return (
<div
className={cn(
"bg-black cursor-pointer",
"transition-all duration-300",
"p-8",
className
)}
onClick={onClick}
>
{/* Progress Bar Container */}
<div className="w-full">
{/* Old-school progress bar with overlaid text */}
<div className="w-full border-4 border-red-500 p-2 bg-black relative overflow-hidden">
{/* Inner container */}
<div className="relative h-8">
{/* Progress fill */}
<div
className="absolute top-0 left-0 h-full bg-red-500 transition-all duration-500 ease-out"
style={{ width: `${progressPercentage}%` }}
/>
{/* Text overlay */}
{selectedMilestone && (
<div className="relative h-full flex items-center justify-center">
{/* Base text (red on black background) */}
<div className="text-red-500 font-mono text-sm font-bold mix-blend-difference relative z-10">
{progressPercentage.toFixed(1)}%
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,214 @@
import { cn } from '@/lib/utils';
import { Plus, ChevronRight } from 'lucide-react';
import { useState } from 'react';
interface Milestone {
target: number;
description: string;
created_at: string;
}
interface StatsBoxProps {
stats: {
totalShares: number;
totalInvestment: number;
averageCostPerShare: number;
currentPrice?: number;
currentValue?: number;
profitLoss?: number;
profitLossPercentage?: number;
};
milestones?: Milestone[];
selectedMilestoneIndex?: number;
onMilestoneSelect?: (index: number) => void;
className?: string;
onAddPurchase?: () => void;
onAddMilestone?: () => void;
onUpdatePrice?: () => void;
}
export default function StatsBox({
stats,
milestones = [],
selectedMilestoneIndex = 0,
onMilestoneSelect,
className,
onAddPurchase,
onAddMilestone,
onUpdatePrice
}: StatsBoxProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const handleCycleMilestone = () => {
if (milestones.length === 0 || !onMilestoneSelect) return;
const nextIndex = (selectedMilestoneIndex + 1) % milestones.length;
onMilestoneSelect(nextIndex);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const formatCurrencyDetailed = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 4,
}).format(amount);
};
return (
<div
className={cn(
"bg-black p-8",
"transition-all duration-300",
className
)}
>
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4">
{/* STATS Title and Current Price */}
<div className="flex justify-between items-center mb-6 relative">
<h2 className="text-red-500 text-lg font-mono font-bold tracking-wider">
STATS
</h2>
<div className="flex items-center space-x-2 relative">
{stats.currentPrice && (
<div className="text-red-500 text-sm font-mono tracking-wider">
VWCE: {formatCurrencyDetailed(stats.currentPrice)}
</div>
)}
{/* Action Dropdown */}
<div className="relative">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
aria-label="Add actions"
>
<Plus className="w-4 h-4" />
</button>
{/* Dropdown Menu */}
{isDropdownOpen && (
<div className="absolute top-full right-0 mt-2 bg-black border-2 border-red-500/50 rounded shadow-lg min-w-40 z-10">
{onAddPurchase && (
<button
onClick={() => {
onAddPurchase();
setIsDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
>
ADD PURCHASE
</button>
)}
{onAddMilestone && (
<button
onClick={() => {
onAddMilestone();
setIsDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
>
ADD MILESTONE
</button>
)}
{onUpdatePrice && (
<button
onClick={() => {
onUpdatePrice();
setIsDropdownOpen(false);
}}
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
>
UPDATE PRICE
</button>
)}
</div>
)}
</div>
{/* Milestone Cycle Button */}
{milestones.length > 1 && (
<button
onClick={handleCycleMilestone}
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
aria-label="Cycle milestone"
>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Milestone Table */}
<div className="pt-4">
<div className="text-red-500 underline font-bold mb-3 font-mono">MILESTONES</div>
<div className="overflow-x-auto">
<table className="w-full text-sm font-mono">
<thead>
<tr>
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th>
<th className="text-right text-red-500 text-xs py-2">SHARES</th>
<th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th>
<th className="text-right text-red-500 text-xs py-2">SWR 4%</th>
</tr>
</thead>
<tbody>
{/* Current position row */}
<tr className="text-red-500 font-bold">
<td className="py-1 pr-4">CURRENT</td>
<td className="text-right py-1 pr-4">
{Math.floor(stats.totalShares).toLocaleString()}
</td>
<td className="text-right py-1 pr-4">
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'}
</td>
<td className="text-right py-1">
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'}
</td>
</tr>
{/* Render milestones after current */}
{milestones.map((milestone, index) => {
const swr3 = stats.currentPrice ? milestone.target * stats.currentPrice * 0.03 : 0;
const swr4 = stats.currentPrice ? milestone.target * stats.currentPrice * 0.04 : 0;
const isSelectedMilestone = index === selectedMilestoneIndex;
return (
<tr
key={index}
className={cn(
isSelectedMilestone
? "text-red-500 font-bold"
: "bg-red-500 text-black"
)}
>
<td className="py-1 pr-4">
{milestone.description}
</td>
<td className="text-right py-1 pr-4">
{Math.floor(milestone.target).toLocaleString()}
</td>
<td className="text-right py-1 pr-4">
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
</td>
<td className="text-right py-1">
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,82 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
interface MilestoneFormData {
target: string;
description: string;
[key: string]: string;
}
interface AddMilestoneFormProps {
onSuccess?: () => void;
}
export default function AddMilestoneForm({ onSuccess }: AddMilestoneFormProps) {
const { data, setData, post, processing, errors, reset } = useForm<MilestoneFormData>({
target: '',
description: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('milestones.store'), {
onSuccess: () => {
reset();
if (onSuccess) {
onSuccess();
}
},
});
};
return (
<div className="w-full max-w-md">
<div className="space-y-4">
<form onSubmit={submit} className="space-y-4">
<div>
<Label htmlFor="target" className="text-red-400">Target Number</Label>
<Input
id="target"
type="number"
step="1"
min="1"
placeholder="1500"
value={data.target}
onChange={(e) => setData('target', e.target.value)}
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
/>
<InputError message={errors.target} />
</div>
<div>
<Label htmlFor="description" className="text-red-400">Description</Label>
<Input
id="description"
type="text"
placeholder="First milestone"
value={data.description}
onChange={(e) => setData('description', e.target.value)}
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
/>
<InputError message={errors.description} />
</div>
<Button
type="submit"
disabled={processing}
className="w-full bg-red-600 hover:bg-red-700 text-white border-red-500"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
Add Milestone
</Button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
interface PriceUpdateFormData {
date: string;
price: string;
[key: string]: string;
}
interface UpdatePriceFormProps {
currentPrice?: number;
className?: string;
onSuccess?: () => void;
}
export default function UpdatePriceForm({ currentPrice, className, onSuccess }: UpdatePriceFormProps) {
const { data, setData, post, processing, errors } = useForm<PriceUpdateFormData>({
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
price: currentPrice?.toString() || '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('pricing.update'), {
onSuccess: () => {
// Keep the date, reset only price if needed
// User might want to update same day multiple times
if (onSuccess) onSuccess();
},
});
};
return (
<Card className={className}>
<CardHeader>
<CardTitle>Update Asset Price</CardTitle>
{currentPrice && (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Current price: {currentPrice.toFixed(4)}
</p>
)}
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div>
<Label htmlFor="date">Price Date</Label>
<Input
id="date"
type="date"
value={data.date}
onChange={(e) => setData('date', e.target.value)}
max={new Date().toISOString().split('T')[0]}
/>
<InputError message={errors.date} />
</div>
<div>
<Label htmlFor="price">Asset Price ()</Label>
<Input
id="price"
type="number"
step="0.0001"
min="0"
placeholder="123.4567"
value={data.price}
onChange={(e) => setData('price', e.target.value)}
/>
<p className="text-xs text-neutral-500 mt-1">
Price per unit/share of the asset
</p>
<InputError message={errors.price} />
</div>
<Button
type="submit"
disabled={processing}
className="w-full"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
Update Price
</Button>
</form>
</CardContent>
</Card>
);
}

View file

@ -1,5 +1,5 @@
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
import { useAppearance } from '@/hooks/use-appearance';
import { Monitor, Moon, Sun } from 'lucide-react';
import { HTMLAttributes } from 'react';

View file

@ -1,12 +1,12 @@
import { useForm } from '@inertiajs/react';
import { FormEventHandler, useRef } from 'react';
import InputError from '@/components/input-error';
import InputError from '@/components/InputError';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import HeadingSmall from '@/components/heading-small';
import HeadingSmall from '@/components/HeadingSmall';
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog';

View file

@ -1,5 +1,5 @@
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
import { UserInfo } from '@/components/user-info';
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/DropdownMenu';
import { UserInfo } from '@/components/Settings/UserInfo';
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
import { type User } from '@/types';
import { Link, router } from '@inertiajs/react';

View file

@ -0,0 +1,133 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler, useEffect } from 'react';
interface PurchaseFormData {
date: string;
shares: string;
price_per_share: string;
total_cost: string;
[key: string]: string;
}
interface AddPurchaseFormProps {
onSuccess?: () => void;
}
export default function AddPurchaseForm({ onSuccess }: AddPurchaseFormProps) {
const { data, setData, post, processing, errors, reset } = useForm<PurchaseFormData>({
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
shares: '',
price_per_share: '',
total_cost: '',
});
// Auto-calculate total cost when shares or price changes
useEffect(() => {
if (data.shares && data.price_per_share) {
const shares = parseFloat(data.shares);
const pricePerShare = parseFloat(data.price_per_share);
if (!isNaN(shares) && !isNaN(pricePerShare)) {
const totalCost = (shares * pricePerShare).toFixed(2);
setData('total_cost', totalCost);
}
}
}, [data.shares, data.price_per_share, setData]);
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('purchases.store'), {
onSuccess: () => {
reset();
setData('date', new Date().toISOString().split('T')[0]);
if (onSuccess) {
onSuccess();
}
},
});
};
return (
<div className="w-full max-w-md">
<div className="space-y-4">
<form onSubmit={submit} className="space-y-4">
<div>
<Label htmlFor="date" className="text-red-400">Purchase Date</Label>
<Input
id="date"
type="date"
value={data.date}
onChange={(e) => setData('date', e.target.value)}
max={new Date().toISOString().split('T')[0]}
className="bg-black border-red-500/30 text-red-400 focus:border-red-400"
/>
<InputError message={errors.date} />
</div>
<div>
<Label htmlFor="shares" className="text-red-400">Number of Shares</Label>
<Input
id="shares"
type="number"
step="0.000001"
min="0"
placeholder="1.234567"
value={data.shares}
onChange={(e) => setData('shares', e.target.value)}
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
/>
<InputError message={errors.shares} />
</div>
<div>
<Label htmlFor="price_per_share" className="text-red-400">Price per Share ()</Label>
<Input
id="price_per_share"
type="number"
step="0.01"
min="0"
placeholder="123.45"
value={data.price_per_share}
onChange={(e) => setData('price_per_share', e.target.value)}
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
/>
<InputError message={errors.price_per_share} />
</div>
<div>
<Label htmlFor="total_cost" className="text-red-400">Total Cost ()</Label>
<Input
id="total_cost"
type="number"
step="0.01"
min="0"
placeholder="1234.56"
value={data.total_cost}
onChange={(e) => setData('total_cost', e.target.value)}
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
/>
<p className="text-xs text-red-400/60 mt-1">
Auto-calculated from shares × price
</p>
<InputError message={errors.total_cost} />
</div>
<Button
type="submit"
disabled={processing}
className="w-full bg-red-600 hover:bg-red-700 text-white border-red-500"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
Add Purchase
</Button>
</form>
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { AppContent } from '@/components/app-content';
import { AppHeader } from '@/components/app-header';
import { AppShell } from '@/components/app-shell';
import { AppContent } from '@/components/Display/AppContent';
import { AppHeader } from '@/components/Display/AppHeader';
import { AppShell } from '@/components/Display/AppShell';
import { type BreadcrumbItem } from '@/types';
import type { PropsWithChildren } from 'react';

View file

@ -1,7 +1,7 @@
import { AppContent } from '@/components/app-content';
import { AppShell } from '@/components/app-shell';
import { AppSidebar } from '@/components/app-sidebar';
import { AppSidebarHeader } from '@/components/app-sidebar-header';
import { AppContent } from '@/components/Display/AppContent';
import { AppShell } from '@/components/Display/AppShell';
import { AppSidebar } from '@/components/Display/AppSidebar';
import { AppSidebarHeader } from '@/components/Display/AppSidebarHeader';
import { type BreadcrumbItem } from '@/types';
import { type PropsWithChildren } from 'react';

View file

@ -1,4 +1,4 @@
import AppLogoIcon from '@/components/app-logo-icon';
import AppLogoIcon from '@/components/Display/AppLogoIcon';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Link } from '@inertiajs/react';
import { type PropsWithChildren } from 'react';

View file

@ -1,4 +1,4 @@
import AppLogoIcon from '@/components/app-logo-icon';
import AppLogoIcon from '@/components/Display/AppLogoIcon';
import { Link } from '@inertiajs/react';
import { type PropsWithChildren } from 'react';

View file

@ -1,4 +1,4 @@
import AppLogoIcon from '@/components/app-logo-icon';
import AppLogoIcon from '@/components/Display/AppLogoIcon';
import { type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { type PropsWithChildren } from 'react';

View file

@ -3,7 +3,7 @@ import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import InputError from '@/components/input-error';
import InputError from '@/components/InputError';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

View file

@ -3,8 +3,8 @@ import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
import InputError from '@/components/InputError';
import TextLink from '@/components/TextLink';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

View file

@ -2,8 +2,8 @@ import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
import InputError from '@/components/InputError';
import TextLink from '@/components/TextLink';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';

View file

@ -2,8 +2,8 @@ import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
import InputError from '@/components/InputError';
import TextLink from '@/components/TextLink';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

View file

@ -2,7 +2,7 @@ import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import InputError from '@/components/input-error';
import InputError from '@/components/InputError';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

View file

@ -3,7 +3,7 @@ import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import TextLink from '@/components/text-link';
import TextLink from '@/components/TextLink';
import { Button } from '@/components/ui/button';
import AuthLayout from '@/layouts/auth-layout';

View file

@ -1,35 +1,225 @@
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import AppLayout from '@/layouts/app-layout';
import { type BreadcrumbItem } from '@/types';
import LedDisplay from '@/components/Display/LedDisplay';
import InlineForm from '@/components/Display/InlineForm';
import ProgressBar from '@/components/Display/ProgressBar';
import StatsBox from '@/components/Display/StatsBox';
import { Head } from '@inertiajs/react';
import { useEffect, useState } from 'react';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Dashboard',
href: '/dashboard',
},
];
interface PurchaseSummary {
total_shares: number;
total_investment: number;
average_cost_per_share: number;
}
interface CurrentPrice {
current_price: number | null;
}
interface Milestone {
target: number;
description: string;
created_at: string;
}
export default function Dashboard() {
const [purchaseData, setPurchaseData] = useState<PurchaseSummary>({
total_shares: 0,
total_investment: 0,
average_cost_per_share: 0,
});
const [priceData, setPriceData] = useState<CurrentPrice>({
current_price: null,
});
const [milestones, setMilestones] = useState<Milestone[]>([]);
const [selectedMilestoneIndex, setSelectedMilestoneIndex] = useState(0);
const [showProgressBar, setShowProgressBar] = useState(false);
const [showStatsBox, setShowStatsBox] = useState(false);
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | 'price' | null>(null);
const [loading, setLoading] = useState(true);
// Fetch purchase summary, current price, and milestones
useEffect(() => {
const fetchData = async () => {
try {
const [purchaseResponse, priceResponse, milestonesResponse] = await Promise.all([
fetch('/purchases/summary'),
fetch('/pricing/current'),
fetch('/milestones'),
]);
if (purchaseResponse.ok) {
const purchases = await purchaseResponse.json();
setPurchaseData(purchases);
}
if (priceResponse.ok) {
const price = await priceResponse.json();
setPriceData(price);
}
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
}
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// Refresh data after successful purchase
const handlePurchaseSuccess = async () => {
try {
const purchaseResponse = await fetch('/purchases/summary');
if (purchaseResponse.ok) {
const purchases = await purchaseResponse.json();
setPurchaseData(purchases);
}
} catch (error) {
console.error('Failed to refresh purchase data:', error);
}
};
// Refresh milestones after successful creation
const handleMilestoneSuccess = async () => {
try {
const milestonesResponse = await fetch('/milestones');
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
// Reset to first milestone when milestones change
setSelectedMilestoneIndex(0);
}
} catch (error) {
console.error('Failed to refresh milestone data:', error);
}
};
// Handle milestone selection
const handleMilestoneSelect = (index: number) => {
setSelectedMilestoneIndex(index);
};
// Refresh price data after successful update
const handlePriceSuccess = async () => {
try {
const priceResponse = await fetch('/pricing/current');
if (priceResponse.ok) {
const price = await priceResponse.json();
setPriceData(price);
}
} catch (error) {
console.error('Failed to refresh price data:', error);
}
};
// Calculate portfolio stats
const currentValue = priceData.current_price
? purchaseData.total_shares * priceData.current_price
: undefined;
const profitLoss = currentValue
? currentValue - purchaseData.total_investment
: undefined;
const profitLossPercentage = profitLoss && purchaseData.total_investment > 0
? (profitLoss / purchaseData.total_investment) * 100
: undefined;
const statsData = {
totalShares: purchaseData.total_shares,
totalInvestment: purchaseData.total_investment,
averageCostPerShare: purchaseData.average_cost_per_share,
currentPrice: priceData.current_price || undefined,
currentValue,
profitLoss,
profitLossPercentage,
};
if (loading) {
return (
<AppLayout breadcrumbs={breadcrumbs}>
<>
<Head title="Dashboard" />
<div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4 overflow-x-auto">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
<div className="min-h-screen bg-black flex items-center justify-center">
<div className="text-red-500 font-mono text-lg animate-pulse">
LOADING...
</div>
</div>
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
</div>
</AppLayout>
</>
);
}
// Toggle handlers with cascading behavior
const handleLedClick = () => {
const newShowProgressBar = !showProgressBar;
setShowProgressBar(newShowProgressBar);
if (!newShowProgressBar) {
// If hiding progress bar, also hide stats box
setShowStatsBox(false);
}
};
const handleProgressClick = () => {
setShowStatsBox(!showStatsBox);
};
return (
<>
<Head title="VWCE Tracker" />
{/* Stacked Layout */}
<div className="min-h-screen bg-black">
<div className="w-full max-w-4xl mx-auto px-4">
{/* Box 1: LED Number Display - Fixed position from top */}
<div className="pt-32">
<LedDisplay
value={purchaseData.total_shares}
onClick={handleLedClick}
/>
</div>
{/* Box 2: Progress Bar (toggleable) */}
<div className="mt-4" style={{ display: showProgressBar ? 'block' : 'none' }}>
<ProgressBar
currentShares={purchaseData.total_shares}
milestones={milestones}
selectedMilestoneIndex={selectedMilestoneIndex}
onClick={handleProgressClick}
/>
</div>
{/* Box 3: Stats Box (toggleable) */}
<div className="mt-4" style={{ display: showStatsBox ? 'block' : 'none' }}>
<StatsBox
stats={statsData}
milestones={milestones}
selectedMilestoneIndex={selectedMilestoneIndex}
onMilestoneSelect={handleMilestoneSelect}
onAddPurchase={() => setActiveForm('purchase')}
onAddMilestone={() => setActiveForm('milestone')}
onUpdatePrice={() => setActiveForm('price')}
/>
</div>
{/* Box 4: Forms (only when active form is set) */}
<div className="mt-4" style={{ display: activeForm ? 'block' : 'none' }}>
<InlineForm
type={activeForm}
onClose={() => setActiveForm(null)}
onPurchaseSuccess={handlePurchaseSuccess}
onMilestoneSuccess={handleMilestoneSuccess}
onPriceSuccess={handlePriceSuccess}
/>
</div>
</div>
</div>
</>
);
}

View file

@ -1,7 +1,7 @@
import { Head } from '@inertiajs/react';
import AppearanceTabs from '@/components/appearance-tabs';
import HeadingSmall from '@/components/heading-small';
import AppearanceTabs from '@/components/Settings/AppearanceTabs';
import HeadingSmall from '@/components/HeadingSmall';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';

View file

@ -1,4 +1,4 @@
import InputError from '@/components/input-error';
import InputError from '@/components/InputError';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { type BreadcrumbItem } from '@/types';
@ -6,7 +6,7 @@ import { Transition } from '@headlessui/react';
import { Head, useForm } from '@inertiajs/react';
import { FormEventHandler, useRef } from 'react';
import HeadingSmall from '@/components/heading-small';
import HeadingSmall from '@/components/HeadingSmall';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

View file

@ -3,9 +3,9 @@ import { Transition } from '@headlessui/react';
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { FormEventHandler } from 'react';
import DeleteUser from '@/components/delete-user';
import HeadingSmall from '@/components/heading-small';
import InputError from '@/components/input-error';
import DeleteUser from '@/components/Settings/DeleteUser';
import HeadingSmall from '@/components/HeadingSmall';
import InputError from '@/components/InputError';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

View file

@ -37,7 +37,9 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600|major-mono-display:400" rel="stylesheet" />
<link rel="preload" href="/fonts/7segment.woff" as="font" type="font/woff" crossorigin>
@routes
@viteReactRefresh

View file

@ -1,16 +1,39 @@
<?php
use App\Http\Controllers\Transactions\PurchaseController;
use App\Http\Controllers\Pricing\PricingController;
use App\Http\Controllers\Milestones\MilestoneController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () {
return Inertia::render('welcome');
return redirect('/dashboard');
})->name('home');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', function () {
return Inertia::render('dashboard');
})->name('dashboard');
// Purchase routes
Route::prefix('purchases')->name('purchases.')->group(function () {
Route::get('/', [PurchaseController::class, 'index'])->name('index');
Route::post('/', [PurchaseController::class, 'store'])->name('store');
Route::get('/summary', [PurchaseController::class, 'summary'])->name('summary');
Route::delete('/{purchase}', [PurchaseController::class, 'destroy'])->name('destroy');
});
// Pricing routes
Route::prefix('pricing')->name('pricing.')->group(function () {
Route::get('/current', [PricingController::class, 'current'])->name('current');
Route::post('/update', [PricingController::class, 'update'])->name('update');
Route::get('/history', [PricingController::class, 'history'])->name('history');
Route::get('/date/{date}', [PricingController::class, 'forDate'])->name('for-date');
});
// Milestone routes
Route::prefix('milestones')->name('milestones.')->group(function () {
Route::get('/', [MilestoneController::class, 'index'])->name('index');
Route::post('/', [MilestoneController::class, 'store'])->name('store');
});
require __DIR__.'/settings.php';

View file

@ -0,0 +1,59 @@
<?php
namespace Tests\Feature;
use App\Models\Milestone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MilestoneTest extends TestCase
{
use RefreshDatabase;
public function test_can_create_milestone(): void
{
$milestone = Milestone::create([
'target' => 1500,
'description' => 'First milestone'
]);
$this->assertDatabaseHas('milestones', [
'target' => 1500,
'description' => 'First milestone'
]);
$this->assertEquals(1500, $milestone->target);
$this->assertEquals('First milestone', $milestone->description);
}
public function test_can_fetch_milestones_via_api(): void
{
// Create test milestones
Milestone::create(['target' => 1500, 'description' => 'First milestone']);
Milestone::create(['target' => 3000, 'description' => 'Second milestone']);
$response = $this->get('/milestones');
$response->assertStatus(200);
$response->assertJsonCount(2);
$response->assertJson([
['target' => 1500, 'description' => 'First milestone'],
['target' => 3000, 'description' => 'Second milestone']
]);
}
public function test_milestones_ordered_by_target(): void
{
// Create milestones in reverse order
Milestone::create(['target' => 3000, 'description' => 'Third']);
Milestone::create(['target' => 1000, 'description' => 'First']);
Milestone::create(['target' => 2000, 'description' => 'Second']);
$response = $this->get('/milestones');
$milestones = $response->json();
$this->assertEquals(1000, $milestones[0]['target']);
$this->assertEquals(2000, $milestones[1]['target']);
$this->assertEquals(3000, $milestones[2]['target']);
}
}