Compare commits

...

8 commits

13 changed files with 173 additions and 558 deletions

View file

@ -32,9 +32,9 @@ jobs:
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
echo "tags=forge.lvl0.xyz/myrmidex/incr:${TAG},forge.lvl0.xyz/myrmidex/incr:latest" >> $GITHUB_OUTPUT
echo "tags=forge.lvl0.xyz/lvl0/incr:${TAG},forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
else
echo "tags=forge.lvl0.xyz/myrmidex/incr:latest" >> $GITHUB_OUTPUT
echo "tags=forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
fi
- name: Build and push

View file

@ -7,6 +7,7 @@
use App\Models\Asset;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TrackerController extends Controller
@ -16,10 +17,10 @@ public function show(): JsonResponse
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json(null);
return response()->json(['exists' => false]);
}
return response()->json($tracker->load('asset'));
return response()->json(['exists' => true, 'tracker' => $tracker->load('asset')]);
}
public function store(Request $request): JsonResponse
@ -54,7 +55,7 @@ public function store(Request $request): JsonResponse
return response()->json($tracker->load('asset'), 201);
}
public function update(Request $request): JsonResponse
public function update(Request $request): RedirectResponse|JsonResponse
{
$validated = $request->validate([
'label' => 'sometimes|string|max:255',
@ -67,7 +68,7 @@ public function update(Request $request): JsonResponse
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json(['error' => 'No tracker found.'], 404);
return back()->withErrors(['tracker' => 'No tracker found.']);
}
if (array_key_exists('symbol', $validated)) {
@ -95,6 +96,6 @@ public function update(Request $request): JsonResponse
$tracker->update($update);
return response()->json($tracker->load('asset'));
return back();
}
}

View file

@ -1,33 +0,0 @@
<?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,30 @@
<?php
declare(strict_types=1);
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('entries', function (Blueprint $table): void {
$table->id();
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
$table->date('date');
$table->decimal('quantity', 12, 6);
$table->decimal('unit_price', 12, 4)->nullable();
$table->decimal('total_cost', 12, 2)->nullable();
$table->timestamps();
$table->index(['tracker_id', 'date']);
});
}
public function down(): void
{
Schema::dropIfExists('entries');
}
};

View file

@ -1,50 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Rename table
Schema::rename('purchases', 'entries');
Schema::table('entries', function (Blueprint $table) {
// Rename columns
$table->renameColumn('shares', 'quantity');
$table->renameColumn('price_per_share', 'unit_price');
// Add tracker_id FK (nullable first so we can backfill)
$table->foreignId('tracker_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
});
// Backfill tracker_id: assign all entries to the first tracker (single-user app)
$trackerId = DB::table('trackers')->value('id');
if ($trackerId) {
DB::table('entries')->update(['tracker_id' => $trackerId]);
}
// Make tracker_id non-nullable now that it's backfilled
Schema::table('entries', function (Blueprint $table) {
$table->unsignedBigInteger('tracker_id')->nullable(false)->change();
});
}
public function down(): void
{
Schema::table('entries', function (Blueprint $table) {
$table->dropForeign(['tracker_id']);
$table->dropColumn('tracker_id');
});
Schema::table('entries', function (Blueprint $table) {
$table->renameColumn('unit_price', 'price_per_share');
$table->renameColumn('quantity', 'shares');
});
Schema::rename('entries', 'purchases');
}
};

View file

@ -30,7 +30,7 @@ export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormPr
try {
const response = await fetch('/tracker');
if (response.ok) {
const tracker = await response.json();
const { tracker } = await response.json();
if (tracker?.asset) {
setData({
symbol: tracker.asset.symbol || '',

View file

@ -1,14 +1,12 @@
import AddEntryForm from '@/components/Transactions/AddEntryForm';
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
import { cn } from '@/lib/utils';
type FormType = 'purchase' | 'milestone' | 'price';
type FormType = 'purchase' | 'milestone';
interface InlineFormProps {
type: FormType | null;
unit?: string;
priceTrackingEnabled?: boolean;
onClose: () => void;
onSuccess?: (type: FormType) => void;
className?: string;
@ -17,7 +15,6 @@ interface InlineFormProps {
export default function InlineForm({
type,
unit = 'units',
priceTrackingEnabled = false,
onClose,
onSuccess,
className,
@ -42,17 +39,11 @@ export default function InlineForm({
{type === 'purchase' ? (
<AddEntryForm
unit={unit}
priceTrackingEnabled={priceTrackingEnabled}
onSuccess={handleSuccess}
onCancel={onClose}
/>
) : type === 'milestone' ? (
<AddMilestoneForm
onSuccess={handleSuccess}
onCancel={onClose}
/>
) : (
<UpdatePriceForm
<AddMilestoneForm
onSuccess={handleSuccess}
onCancel={onClose}
/>

View file

@ -7,12 +7,6 @@ import type { Milestone } from '@/types/domain';
interface StatsBoxProps {
stats: {
totalShares: number;
totalInvestment: number;
averageCostPerShare: number;
currentPrice?: number;
currentValue?: number;
profitLoss?: number;
profitLossPercentage?: number;
};
unit?: string;
milestones?: Milestone[];
@ -21,9 +15,6 @@ interface StatsBoxProps {
className?: string;
onAddPurchase?: () => void;
onAddMilestone?: () => void;
onUpdatePrice?: () => void;
assetSymbol?: string;
priceTrackingEnabled?: boolean;
}
export default function StatsBox({
@ -35,9 +26,6 @@ export default function StatsBox({
className,
onAddPurchase,
onAddMilestone,
onUpdatePrice,
assetSymbol,
priceTrackingEnabled = false,
}: StatsBoxProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
@ -46,22 +34,6 @@ export default function StatsBox({
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
@ -72,28 +44,20 @@ export default function StatsBox({
)}
>
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
{/* STATS Title and Current Price */}
<div className="flex justify-between items-center mb-6 relative">
<ComponentTitle>Stats</ComponentTitle>
<div className="flex items-center space-x-2 relative">
{priceTrackingEnabled && stats.currentPrice && (
<div className="text-red-500 text-sm font-mono tracking-wider">
{assetSymbol ?? 'PRICE'}: {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"
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 && (
@ -113,22 +77,11 @@ export default function StatsBox({
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"
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 MILESTONE
</button>
)}
{priceTrackingEnabled && 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>
@ -146,78 +99,45 @@ export default function StatsBox({
</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">{unit.toUpperCase()}</th>
{priceTrackingEnabled && <th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th>}
{priceTrackingEnabled && <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>
{priceTrackingEnabled && (
<td className="text-right py-1 pr-4">
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'}
</td>
)}
{priceTrackingEnabled && (
{/* 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">{unit.toUpperCase()}</th>
</tr>
</thead>
<tbody>
<tr className="text-red-500 font-bold">
<td className="py-1 pr-4">CURRENT</td>
<td className="text-right py-1">
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'}
{Math.floor(stats.totalShares).toLocaleString()}
</td>
)}
</tr>
</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 (
{milestones.map((milestone, index) => (
<tr
key={index}
className={cn(
isSelectedMilestone
index === selectedMilestoneIndex
? "bg-red-500 text-black"
: "text-red-500 font-bold"
)}
>
<td className="py-1 pr-4">
{milestone.description}
</td>
<td className="text-right py-1 pr-4">
<td className="py-1 pr-4">{milestone.description}</td>
<td className="text-right py-1">
{Math.floor(milestone.target).toLocaleString()}
</td>
{priceTrackingEnabled && (
<td className="text-right py-1 pr-4">
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
</td>
)}
{priceTrackingEnabled && (
<td className="text-right py-1">
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
</td>
)}
</tr>
);
})}
</tbody>
</table>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -2,53 +2,55 @@ 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, useState } from 'react';
import ComponentTitle from '@/components/ui/ComponentTitle';
interface TrackerFormData {
label: string;
unit: string;
price_tracking_enabled: string;
symbol: string;
full_name: string;
[key: string]: string;
}
interface CreateTrackerStepProps {
onSuccess: (priceTrackingEnabled: boolean) => void;
onSuccess: () => void;
}
export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps) {
const [priceTracking, setPriceTracking] = useState(false);
const [label, setLabel] = useState('');
const [unit, setUnit] = useState('');
const [processing, setProcessing] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data, setData, post, processing, errors } = useForm<TrackerFormData>({
label: '',
unit: '',
price_tracking_enabled: '0',
symbol: '',
full_name: '',
});
const togglePriceTracking = (enabled: boolean) => {
setPriceTracking(enabled);
setData({
...data,
price_tracking_enabled: enabled ? '1' : '0',
symbol: enabled ? data.symbol : '',
full_name: enabled ? data.full_name : '',
});
};
const submit: FormEventHandler = (e) => {
const submit: FormEventHandler = async (e) => {
e.preventDefault();
setProcessing(true);
setErrors({});
post(route('tracker.store'), {
onSuccess: () => {
onSuccess(priceTracking);
},
});
try {
const response = await fetch(route('tracker.store'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
'Accept': 'application/json',
},
body: JSON.stringify({
label,
unit,
price_tracking_enabled: 0,
}),
});
if (response.ok || response.status === 201 || response.status === 409) {
onSuccess();
} else {
const data = await response.json();
if (data.errors) {
setErrors(data.errors);
} else if (data.message) {
setErrors({ label: data.message });
}
}
} catch {
setErrors({ label: 'Something went wrong. Please try again.' });
} finally {
setProcessing(false);
}
};
return (
@ -68,8 +70,8 @@ export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps)
id="label"
type="text"
placeholder="My Portfolio"
value={data.label}
onChange={(e) => setData('label', e.target.value)}
value={label}
onChange={(e) => setLabel(e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
/>
<p className="text-xs text-red-400/60 mt-1 font-mono">
@ -86,8 +88,8 @@ export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps)
id="unit"
type="text"
placeholder="shares"
value={data.unit}
onChange={(e) => setData('unit', e.target.value)}
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
/>
<p className="text-xs text-red-400/60 mt-1 font-mono">
@ -96,59 +98,9 @@ export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps)
<InputError message={errors.unit} />
</div>
<div className="border border-red-500/30 p-4 space-y-3">
<label className="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
checked={priceTracking}
onChange={(e) => togglePriceTracking(e.target.checked)}
className="w-4 h-4 accent-red-500"
/>
<span className="text-red-400 font-mono text-sm uppercase tracking-wider group-hover:text-red-300">
Enable price tracking
</span>
</label>
<p className="text-red-400/60 font-mono text-xs">
Track market price, portfolio value, and P&amp;L. Requires an asset symbol.
</p>
{priceTracking && (
<div className="space-y-3 pt-2">
<div>
<Label htmlFor="symbol" className="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Asset Symbol
</Label>
<Input
id="symbol"
type="text"
placeholder="VWCE"
value={data.symbol}
onChange={(e) => setData('symbol', e.target.value.toUpperCase())}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
/>
<InputError message={errors.symbol} />
</div>
<div>
<Label htmlFor="full_name" className="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Full Name (Optional)
</Label>
<Input
id="full_name"
type="text"
placeholder="Vanguard FTSE All-World UCITS ETF"
value={data.full_name}
onChange={(e) => setData('full_name', e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
/>
<InputError message={errors.full_name} />
</div>
</div>
)}
</div>
<Button
type="submit"
disabled={processing || !data.label || !data.unit || (priceTracking && !data.symbol)}
disabled={processing || !label || !unit}
className="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}

View file

@ -1,48 +1,8 @@
import { useState, useEffect, useCallback } from 'react';
import AssetSetupForm from '@/components/Assets/AssetSetupForm';
import AddEntryForm from '@/components/Transactions/AddEntryForm';
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
import CreateTrackerStep from '@/components/Onboarding/CreateTrackerStep';
function PriceTrackingStep({ onEnable, onSkip }: { onEnable: () => void; onSkip?: () => void }) {
const [enabled, setEnabled] = useState(false);
return (
<div className="space-y-6">
<label className="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
className="w-4 h-4 accent-red-500"
/>
<span className="text-red-400 font-mono text-sm uppercase tracking-wider group-hover:text-red-300">
Enable price tracking (optional)
</span>
</label>
<p className="text-red-400/60 font-mono text-xs">
Track the current market price of your asset to see portfolio value and P&amp;L. You can enable this later in settings.
</p>
{enabled && (
<div className="border border-red-500/30 p-4">
<UpdatePriceForm onSuccess={onEnable} />
</div>
)}
{!enabled && (
<button
onClick={onSkip ?? (() => {})}
className="w-full py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 text-red-400 hover:bg-red-950/30 hover:text-red-300 transition-colors"
>
Skip and finish
</button>
)}
</div>
);
}
interface OnboardingStep {
id: string;
title: string;
@ -51,13 +11,7 @@ interface OnboardingStep {
required: boolean;
}
const ASSET_STEPS: OnboardingStep[] = [
{ id: 'entries', title: 'ADD ENTRIES', description: 'Enter your current holdings', completed: false, required: true },
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
{ id: 'price', title: 'CURRENT PRICE', description: 'Set current asset price (optional)', completed: false, required: false },
];
const SIMPLE_STEPS: OnboardingStep[] = [
const STEPS: OnboardingStep[] = [
{ id: 'entries', title: 'STARTING AMOUNT', description: 'Enter your starting amount', completed: false, required: true },
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
];
@ -68,28 +22,36 @@ interface OnboardingFlowProps {
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const [trackerCreated, setTrackerCreated] = useState(false);
const [priceTracking, setPriceTracking] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [steps, setSteps] = useState<OnboardingStep[]>([]);
// On mount: check if a tracker already exists and skip step 1 if so
useEffect(() => {
fetch('/tracker')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.tracker) {
setTrackerCreated(true);
}
})
.catch(() => {});
}, []);
const checkOnboardingStatus = useCallback(async (currentSteps: OnboardingStep[]) => {
try {
const [entriesData, milestonesData, priceData] = await Promise.all([
const [entriesData, milestonesData] = await Promise.all([
fetch('/entries/summary').then(r => r.json()),
fetch('/milestones').then(r => r.json()),
fetch('/pricing/current').then(r => r.json()),
]);
const hasEntries = entriesData.total_quantity > 0;
const hasMilestones = milestonesData.length > 0;
const hasPrice = !!priceData.current_price;
const freshSteps = currentSteps.map(step => ({
...step,
completed:
(step.id === 'entries' && hasEntries) ||
(step.id === 'milestones' && hasMilestones) ||
(step.id === 'price' && hasPrice),
(step.id === 'milestones' && hasMilestones),
}));
setSteps(freshSteps);
@ -109,14 +71,12 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
useEffect(() => {
if (!trackerCreated) return;
const initialSteps = priceTracking ? ASSET_STEPS : SIMPLE_STEPS;
setSteps(initialSteps);
setSteps(STEPS);
setCurrentStep(0);
checkOnboardingStatus(initialSteps);
}, [trackerCreated, priceTracking, checkOnboardingStatus]);
checkOnboardingStatus(STEPS);
}, [trackerCreated, checkOnboardingStatus]);
const handleTrackerCreated = (withPriceTracking: boolean) => {
setPriceTracking(withPriceTracking);
const handleTrackerCreated = () => {
setTrackerCreated(true);
};
@ -138,11 +98,9 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
switch (step.id) {
case 'entries':
return <AddEntryForm onSuccess={handleStepComplete} priceTrackingEnabled={priceTracking} />;
return <AddEntryForm onSuccess={handleStepComplete} />;
case 'milestones':
return <AddMilestoneForm onSuccess={handleStepComplete} />;
case 'price':
return <PriceTrackingStep onEnable={handleStepComplete} onSkip={onComplete ?? (() => {})} />;
default:
return null;
}
@ -181,7 +139,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
: 'bg-black text-red-400/60 hover:text-red-400 hover:border-red-400'
} ${index > 0 ? 'ml-2' : ''}`}
>
{step.completed ? '[✓]' : step.required ? '[REQ]' : '[OPT]'} {step.title}
{step.completed ? '[✓]' : '[REQ]'} {step.title}
</button>
))}
</div>
@ -206,7 +164,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
</p>
<p className="text-red-400/60 font-mono text-xs">
{steps.filter(s => s.required && !s.completed).length} REQUIRED REMAINING
{steps.filter(s => !s.completed).length} REQUIRED REMAINING
</p>
</div>
</div>

View file

@ -11,30 +11,23 @@ import ComponentTitle from '@/components/ui/ComponentTitle';
interface EntryFormData {
date: string;
quantity: string;
unit_price: string;
total_cost: string;
[key: string]: string;
}
interface AddEntryFormProps {
unit?: string;
priceTrackingEnabled?: boolean;
onSuccess?: () => void;
onCancel?: () => void;
}
interface EntrySummary {
total_quantity: number;
total_cost: number;
average_cost_per_unit: number;
}
export default function AddEntryForm({ unit = 'units', priceTrackingEnabled = false, onSuccess, onCancel }: AddEntryFormProps) {
export default function AddEntryForm({ unit = 'units', onSuccess, onCancel }: AddEntryFormProps) {
const { data, setData, post, processing, errors, reset } = useForm<EntryFormData>({
date: todayISO(),
quantity: '',
unit_price: '',
total_cost: '',
});
const [currentHoldings, setCurrentHoldings] = useState<EntrySummary | null>(null);
@ -55,18 +48,6 @@ export default function AddEntryForm({ unit = 'units', priceTrackingEnabled = fa
fetchSummary();
}, []);
// Auto-calculate total cost when quantity or unit_price changes
useEffect(() => {
if (data.quantity && data.unit_price) {
const quantity = parseFloat(data.quantity);
const unitPrice = parseFloat(data.unit_price);
if (!isNaN(quantity) && !isNaN(unitPrice)) {
setData('total_cost', (quantity * unitPrice).toFixed(2));
}
}
}, [data.quantity, data.unit_price, setData]);
const submit: FormEventHandler = (e) => {
e.preventDefault();
@ -86,7 +67,6 @@ export default function AddEntryForm({ unit = 'units', priceTrackingEnabled = fa
{currentHoldings && currentHoldings.total_quantity > 0 && (
<p className="text-sm text-red-400/60 font-mono">
[CURRENT] {currentHoldings.total_quantity.toFixed(6)} {unit}
{priceTrackingEnabled && ` • €${currentHoldings.total_cost.toFixed(2)} spent`}
</p>
)}
<form onSubmit={submit} className="space-y-4">
@ -120,41 +100,6 @@ export default function AddEntryForm({ unit = 'units', priceTrackingEnabled = fa
<InputError message={errors.quantity} />
</div>
{priceTrackingEnabled && (
<>
<div>
<Label htmlFor="unit_price" className="text-red-400 font-mono text-xs uppercase tracking-wider">&gt; Price per {unit} ()</Label>
<Input
id="unit_price"
type="number"
step="0.01"
min="0"
placeholder="123.45"
value={data.unit_price}
onChange={(e) => setData('unit_price', e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
/>
<InputError message={errors.unit_price} />
</div>
<div>
<Label htmlFor="total_cost" className="text-red-400 font-mono text-xs uppercase tracking-wider">&gt; 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 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
/>
<p className="text-xs text-red-400/60 mt-1 font-mono">[AUTO-CALC] quantity × price</p>
<InputError message={errors.total_cost} />
</div>
</>
)}
<div className="flex gap-3 pt-2">
<Button
type="submit"

View file

@ -6,47 +6,28 @@ import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
import TerminalSpinner from '@/components/ui/TerminalSpinner';
import { Head } from '@inertiajs/react';
import { useCallback, useEffect, useState } from 'react';
import type { Milestone, Tracker, TrackerAsset } from '@/types/domain';
import type { Milestone, Tracker } from '@/types/domain';
interface PurchaseSummary {
interface EntrySummary {
total_shares: number;
total_investment: number;
average_cost_per_share: number;
}
interface CurrentPrice {
current_price: number | null;
}
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 [totalShares, setTotalShares] = useState(0);
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 [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
const [loading, setLoading] = useState(true);
const [needsOnboarding, setNeedsOnboarding] = useState(false);
const [tracker, setTracker] = useState<Tracker | null>(null);
const [currentAsset, setCurrentAsset] = useState<TrackerAsset | null>(null);
const [priceTrackingEnabled, setPriceTrackingEnabled] = useState(false);
// Fetch entry summary, current price, milestones, and check onboarding
useEffect(() => {
const fetchData = async () => {
try {
const [entriesResponse, priceResponse, milestonesResponse, trackerResponse] = await Promise.all([
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
fetch('/entries/summary'),
fetch('/pricing/current'),
fetch('/milestones'),
fetch('/tracker'),
]);
@ -56,19 +37,10 @@ export default function Dashboard() {
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setPurchaseData({
total_shares: entries.total_quantity,
total_investment: entries.total_cost,
average_cost_per_share: entries.average_cost_per_unit,
});
setTotalShares(entries.total_quantity);
totalQuantity = entries.total_quantity;
}
if (priceResponse.ok) {
const price = await priceResponse.json();
setPriceData(price);
}
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
@ -76,10 +48,8 @@ export default function Dashboard() {
}
if (trackerResponse.ok) {
const trackerData = await trackerResponse.json();
setTracker(trackerData);
setCurrentAsset(trackerData?.asset ?? null);
setPriceTrackingEnabled(trackerData?.price_tracking_enabled ?? false);
const { tracker: trackerData } = await trackerResponse.json();
setTracker(trackerData ?? null);
}
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
@ -93,31 +63,24 @@ export default function Dashboard() {
fetchData();
}, []);
// Refresh data after successful entry
const handlePurchaseSuccess = async () => {
try {
const entriesResponse = await fetch('/entries/summary');
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setPurchaseData({
total_shares: entries.total_quantity,
total_investment: entries.total_cost,
average_cost_per_share: entries.average_cost_per_unit,
});
setTotalShares(entries.total_quantity);
}
} catch (error) {
console.error('Failed to refresh entry 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) {
@ -125,47 +88,39 @@ export default function Dashboard() {
}
};
// 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);
const handleOnboardingComplete = useCallback(async () => {
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
fetch('/entries/summary'),
fetch('/milestones'),
fetch('/tracker'),
]);
let totalQuantity = 0;
let milestonesCount = 0;
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setTotalShares(entries.total_quantity);
totalQuantity = entries.total_quantity;
}
};
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
milestonesCount = milestonesData.length;
}
// Calculate portfolio stats
const currentValue = priceData.current_price
? purchaseData.total_shares * priceData.current_price
: undefined;
if (trackerResponse.ok) {
const { tracker: trackerData } = await trackerResponse.json();
setTracker(trackerData ?? null);
}
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,
};
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
}, []);
if (loading) {
return (
@ -176,64 +131,19 @@ export default function Dashboard() {
);
}
// 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);
setActiveForm(null)
setActiveForm(null);
};
// Handle onboarding completion
const handleOnboardingComplete = useCallback(async () => {
const [entriesResponse, priceResponse, milestonesResponse, trackerResponse] = await Promise.all([
fetch('/entries/summary'),
fetch('/pricing/current'),
fetch('/milestones'),
fetch('/tracker'),
]);
let totalQuantity = 0;
let milestonesCount = 0;
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setPurchaseData({
total_shares: entries.total_quantity,
total_investment: entries.total_cost,
average_cost_per_share: entries.average_cost_per_unit,
});
totalQuantity = entries.total_quantity;
}
if (priceResponse.ok) {
const price = await priceResponse.json();
setPriceData(price);
}
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
milestonesCount = milestonesData.length;
}
if (trackerResponse.ok) {
const tracker = await trackerResponse.json();
setCurrentAsset(tracker?.asset ?? null);
setPriceTrackingEnabled(tracker?.price_tracking_enabled ?? false);
}
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
}, []);
// Show onboarding if needed
if (needsOnboarding) {
return (
<>
@ -247,55 +157,45 @@ export default function Dashboard() {
<>
<Head title="incr" />
{/* 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}
value={totalShares}
unit={tracker?.unit}
onClick={handleLedClick}
/>
</div>
{/* Box 2: Progress Bar (toggleable) */}
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
<ProgressBar
currentQuantity={purchaseData.total_shares}
currentQuantity={totalShares}
milestones={milestones}
selectedMilestoneIndex={selectedMilestoneIndex}
onClick={handleProgressClick}
/>
</div>
{/* Box 3: Stats Box (toggleable) */}
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
<StatsBox
stats={statsData}
stats={{ totalShares }}
unit={tracker?.unit}
milestones={milestones}
selectedMilestoneIndex={selectedMilestoneIndex}
onMilestoneSelect={handleMilestoneSelect}
onAddPurchase={() => setActiveForm('purchase')}
onAddMilestone={() => setActiveForm('milestone')}
onUpdatePrice={() => setActiveForm('price')}
assetSymbol={currentAsset?.symbol}
priceTrackingEnabled={priceTrackingEnabled}
/>
</div>
{/* Box 4: Forms (only when active form is set) */}
<div style={{ display: activeForm && showProgressBar && showStatsBox ? 'block' : 'none' }}>
<InlineForm
type={activeForm}
unit={tracker?.unit}
priceTrackingEnabled={priceTrackingEnabled}
onClose={() => setActiveForm(null)}
onSuccess={(type) => {
if (type === 'purchase') handlePurchaseSuccess();
else if (type === 'milestone') handleMilestoneSuccess();
else if (type === 'price') handlePriceSuccess();
}}
/>
</div>

View file

@ -31,6 +31,7 @@
</style>
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
@ -43,7 +44,7 @@
@routes
@viteReactRefresh
@vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
@vite(['resources/js/app.tsx'])
@inertiaHead
</head>
<body class="font-sans antialiased">