Compare commits

...

12 commits

Author SHA1 Message Date
6e76ce9c68 34 - Frontend: AddEntryForm, generalize unit labels, update LedDisplay/StatsBox/ProgressBar/InlineForm
All checks were successful
CI / ci (push) Successful in 14m47s
CI / build (push) Successful in 27s
2026-05-02 18:33:41 +02:00
5c1f3bb183 33 - Onboarding: CreateTrackerStep, update OnboardingFlow, fix dashboard fetch URLs 2026-05-02 18:17:42 +02:00
22e3394cb1 32 - Backend: Tracker/Entry models, TrackerController, EntryController, update routes 2026-05-02 17:10:00 +02:00
b66e018b3a 31 - Schema: create trackers, rename purchases to entries, add tracker_id to milestones 2026-05-02 16:54:50 +02:00
2f7a248e9f 43 - Remove accidentally staged testing file 2026-05-02 16:21:12 +02:00
04fbda48fd 43 - Delete dead Breeze auth/settings boilerplate, slim auth routes to register-only 2026-05-02 16:21:01 +02:00
b1d0ab793c 29 - Security hardening: registration gate, input validation, nginx headers, env defaults, user model 2026-05-02 16:14:31 +02:00
27f0ac8568 28 - Refactor: User::default(), eliminate double-fetch, type currentAsset 2026-05-02 15:07:24 +02:00
7a17d4d90c 25 - Upgrade Laravel framework 12 -> 13 2026-05-02 13:01:01 +02:00
1d7c516eb2 25 - Upgrade all packages: inertia 3, vite 8, tinker 3, phpunit 12, lucide 1, ts 6; commit composer.lock 2026-05-02 12:53:56 +02:00
4abceaff7e 25 - Upgrade lucide-react to v1, typescript to v6 2026-05-02 12:40:04 +02:00
464b4083cf 25 - Update npm packages to latest minor/patch versions 2026-05-02 12:36:50 +02:00
60 changed files with 12088 additions and 4181 deletions

View file

@ -1,7 +1,7 @@
APP_NAME=Laravel
APP_ENV=local
APP_ENV=production
APP_KEY=
APP_DEBUG=true
APP_DEBUG=false
APP_URL=http://localhost
APP_LOCALE=en
@ -18,18 +18,18 @@ BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
LOG_LEVEL=error
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=incr
DB_USERNAME=incr_user
DB_PASSWORD=incr_password
DB_PASSWORD=change_me_in_production
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_ENCRYPT=true
SESSION_PATH=/
SESSION_DOMAIN=null

1
.gitignore vendored
View file

@ -12,7 +12,6 @@
.env.production
.phpactor.json
.phpunit.result.cache
/composer.lock
Homestead.json
Homestead.yaml
npm-debug.log

View file

@ -1,62 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Asset;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AssetController extends Controller
{
public function index(): JsonResponse
{
$assets = Asset::orderBy('symbol')->get();
return response()->json($assets);
}
public function current(): JsonResponse
{
// Get the first/default user (since no auth)
$user = User::first();
$asset = $user ? $user->asset : null;
return response()->json([
'asset' => $asset,
'price_tracking_enabled' => $user?->price_tracking_enabled ?? false,
]);
}
public function setCurrent(Request $request)
{
$validated = $request->validate([
'symbol' => 'required|string|max:10',
'full_name' => 'nullable|string|max:255',
]);
$asset = Asset::findOrCreateBySymbol(
$validated['symbol'],
$validated['full_name'] ?? null
);
// Get or create the first/default user (since no auth)
$user = User::first();
if (! $user) {
// Create a default user if none exists
$user = User::create([
'name' => 'Default User',
'email' => 'user@example.com',
'password' => 'password', // This will be hashed automatically
'asset_id' => $asset->id,
]);
} else {
$user->update(['asset_id' => $asset->id]);
}
return back()->with('success', 'Asset set successfully!');
return response()->json(Asset::orderBy('symbol')->get());
}
public function store(Request $request): JsonResponse
@ -81,11 +37,10 @@ public function store(Request $request): JsonResponse
public function show(Asset $asset): JsonResponse
{
$asset->load('assetPrices');
$currentPrice = $asset->currentPrice();
return response()->json([
'asset' => $asset,
'current_price' => $currentPrice,
'current_price' => $asset->currentPrice(),
]);
}

View file

@ -1,51 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
class AuthenticatedSessionController extends Controller
{
/**
* Show the login page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/login', [
'canResetPassword' => Route::has('password.request'),
'status' => $request->session()->get('status'),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View file

@ -1,41 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password page.
*/
public function show(): Response
{
return Inertia::render('auth/confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View file

@ -1,22 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Show the email verification prompt page.
*/
public function __invoke(Request $request): Response|RedirectResponse
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
}
}

View file

@ -1,70 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class NewPasswordController extends Controller
{
/**
* Show the password reset page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PasswordReset) {
return to_route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [__($status)],
]);
}
}

View file

@ -1,42 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PasswordResetLinkController extends Controller
{
/**
* Show the password reset link request page.
*/
public function create(Request $request): Response
{
return Inertia::render('auth/forgot-password', [
'status' => $request->session()->get('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => 'required|email',
]);
Password::sendResetLink(
$request->only('email')
);
return back()->with('status', __('A reset link will be sent if the account exists.'));
}
}

View file

@ -21,6 +21,10 @@ class RegisteredUserController extends Controller
*/
public function create(): Response
{
if (User::exists()) {
abort(403, 'Registration is disabled.');
}
return Inertia::render('auth/register');
}
@ -31,16 +35,20 @@ public function create(): Response
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
if (User::exists()) {
abort(403, 'Registration is disabled.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
$user = User::forceCreate([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
event(new Registered($user));

View file

@ -1,31 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
/** @var MustVerifyEmail $user */
$user = $request->user();
event(new Verified($user));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View file

@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Milestones;
use App\Http\Controllers\Controller;
use App\Models\Milestone;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -12,23 +14,30 @@ class MilestoneController extends Controller
{
public function store(Request $request): RedirectResponse
{
$request->validate([
$validated = $request->validate([
'target' => 'required|integer|min:1',
'description' => 'required|string|max:255',
]);
Milestone::create([
'target' => $request->target,
'description' => $request->description,
]);
$tracker = User::default()->tracker;
if (! $tracker) {
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
}
$tracker->milestones()->create($validated);
return back()->with('success', 'Milestone created successfully');
}
public function index(): JsonResponse
{
$milestones = Milestone::orderBy('target')->get();
$tracker = User::default()->tracker;
return response()->json($milestones);
if (! $tracker) {
return response()->json([]);
}
return response()->json($tracker->milestones()->orderBy('target')->get());
}
}

View file

@ -1,25 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Pricing;
use App\Http\Controllers\Controller;
use App\Models\Pricing\AssetPrice;
use App\Models\Tracker;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PricingController extends Controller
{
private ?Tracker $tracker;
public function __construct()
{
$this->tracker = User::default()->tracker;
}
public function current(): JsonResponse
{
// Get the first/default user (since no auth)
$user = User::first();
$assetId = $user ? $user->asset_id : null;
$price = AssetPrice::current($assetId);
return response()->json([
'current_price' => $price,
'current_price' => AssetPrice::current($this->tracker?->asset_id),
]);
}
@ -30,17 +34,14 @@ public function update(Request $request)
'price' => 'required|numeric|min:0.0001',
]);
// Get the first/default user (since no auth)
$user = User::first();
if (! $user || ! $user->asset_id) {
if (! $this->tracker?->asset_id) {
return back()->withErrors(['asset' => 'Please set an asset first.']);
}
AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
AssetPrice::updatePrice($this->tracker->asset_id, $validated['date'], $validated['price']);
if (! $user->price_tracking_enabled) {
$user->update(['price_tracking_enabled' => true]);
if (! $this->tracker->price_tracking_enabled) {
$this->tracker->update(['price_tracking_enabled' => true]);
}
return back()->with('success', 'Asset price updated successfully!');
@ -48,27 +49,18 @@ public function update(Request $request)
public function history(Request $request): JsonResponse
{
// Get the first/default user (since no auth)
$user = User::first();
$assetId = $user ? $user->asset_id : null;
$limit = min(max(1, $request->integer('limit', 30)), 365);
$limit = $request->get('limit', 30);
$history = AssetPrice::history($assetId, $limit);
return response()->json($history);
return response()->json(AssetPrice::history($this->tracker?->asset_id, $limit));
}
public function forDate(Request $request, string $date): JsonResponse
{
// Get the first/default user (since no auth)
$user = User::first();
$assetId = $user ? $user->asset_id : null;
$price = AssetPrice::forDate($date, $assetId);
validator(['date' => $date], ['date' => 'required|date_format:Y-m-d'])->validate();
return response()->json([
'date' => $date,
'price' => $price,
'price' => AssetPrice::forDate($date, $this->tracker?->asset_id),
]);
}
}

View file

@ -1,39 +0,0 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
class PasswordController extends Controller
{
/**
* Show the user's password settings page.
*/
public function edit(): Response
{
return Inertia::render('settings/password');
}
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back();
}
}

View file

@ -1,63 +0,0 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
]);
}
/**
* Update the user's profile settings.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return to_route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Asset;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TrackerController extends Controller
{
public function show(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json(null);
}
return response()->json($tracker->load('asset'));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'label' => 'required|string|max:255',
'unit' => 'required|string|max:50',
'price_tracking_enabled' => 'boolean',
'symbol' => 'nullable|string|max:10',
'full_name' => 'nullable|string|max:255',
]);
$user = User::default();
if ($user->tracker) {
return response()->json(['error' => 'Tracker already exists.'], 409);
}
$assetId = null;
if (! empty($validated['symbol'])) {
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
$assetId = $asset->id;
}
$tracker = $user->tracker()->create([
'label' => $validated['label'],
'unit' => $validated['unit'],
'price_tracking_enabled' => $validated['price_tracking_enabled'] ?? false,
'asset_id' => $assetId,
]);
return response()->json($tracker->load('asset'), 201);
}
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'label' => 'sometimes|string|max:255',
'unit' => 'sometimes|string|max:50',
'price_tracking_enabled' => 'sometimes|boolean',
'symbol' => 'nullable|string|max:10',
'full_name' => 'nullable|string|max:255',
]);
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json(['error' => 'No tracker found.'], 404);
}
if (array_key_exists('symbol', $validated)) {
if ($validated['symbol']) {
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
$tracker->asset_id = $asset->id;
} else {
$tracker->asset_id = null;
}
}
$update = [];
if (isset($validated['label'])) {
$update['label'] = $validated['label'];
}
if (isset($validated['unit'])) {
$update['unit'] = $validated['unit'];
}
if (array_key_exists('price_tracking_enabled', $validated)) {
$update['price_tracking_enabled'] = $validated['price_tracking_enabled'];
}
if (array_key_exists('symbol', $validated)) {
$update['asset_id'] = $tracker->asset_id;
}
$tracker->update($update);
return response()->json($tracker->load('asset'));
}
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use App\Models\Transactions\Entry;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EntryController extends Controller
{
public function index(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json([]);
}
return response()->json($tracker->entries()->orderBy('date', 'desc')->get());
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'date' => 'required|date|before_or_equal:today',
'quantity' => 'required|numeric|min:0.000001',
'unit_price' => 'nullable|numeric|min:0.01',
'total_cost' => 'nullable|numeric|min:0.01',
]);
$tracker = User::default()->tracker;
if (! $tracker) {
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
}
// If unit_price and total_cost provided, verify the calculation
if (isset($validated['unit_price'], $validated['total_cost'])) {
$calculatedTotal = $validated['quantity'] * $validated['unit_price'];
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
return back()->withErrors([
'total_cost' => 'Total cost does not match quantity × unit price.',
]);
}
}
$tracker->entries()->create($validated);
return back()->with('success', 'Entry added successfully!');
}
public function summary(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json([
'total_quantity' => 0,
'total_cost' => 0,
'average_cost_per_unit' => 0,
]);
}
return response()->json([
'total_quantity' => Entry::totalQuantity($tracker->id),
'total_cost' => Entry::totalCost($tracker->id),
'average_cost_per_unit' => Entry::averageCostPerUnit($tracker->id),
]);
}
public function destroy(Entry $entry): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker || $entry->tracker_id !== $tracker->id) {
return response()->json(['error' => 'Entry not found.'], 404);
}
$entry->delete();
return response()->json([
'success' => true,
'message' => 'Entry deleted successfully!',
]);
}
}

View file

@ -1,71 +0,0 @@
<?php
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use App\Models\Transactions\Purchase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PurchaseController extends Controller
{
public function index(): JsonResponse
{
$purchases = Purchase::orderBy('date', 'desc')->get();
return response()->json($purchases);
}
public function store(Request $request)
{
$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 back()->with('success', '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!',
]);
}
}

View file

@ -44,7 +44,7 @@ public function share(Request $request): array
'name' => config('app.name'),
'quote' => ['message' => trim($message), 'author' => trim($author)],
'auth' => [
'user' => $request->user(),
'user' => $request->user()?->only(['id', 'name', 'email']),
],
'ziggy' => fn (): array => [
...(new Ziggy)->toArray(),

View file

@ -35,11 +35,6 @@ public function assetPrices(): HasMany
return $this->hasMany(Pricing\AssetPrice::class);
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function currentPrice(): ?float
{
$latestPrice = $this->assetPrices()->latest('date')->first();

View file

@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @method static create(array $array)
@ -11,6 +12,7 @@
class Milestone extends Model
{
protected $fillable = [
'tracker_id',
'target',
'description',
];
@ -18,4 +20,9 @@ class Milestone extends Model
protected $casts = [
'target' => 'integer',
];
public function tracker(): BelongsTo
{
return $this->belongsTo(Tracker::class);
}
}

48
app/Models/Tracker.php Normal file
View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\Transactions\Entry;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tracker extends Model
{
protected $fillable = [
'user_id',
'asset_id',
'label',
'unit',
'price_tracking_enabled',
];
protected function casts(): array
{
return [
'price_tracking_enabled' => 'boolean',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function asset(): BelongsTo
{
return $this->belongsTo(Asset::class);
}
public function entries(): HasMany
{
return $this->hasMany(Entry::class);
}
public function milestones(): HasMany
{
return $this->hasMany(Milestone::class);
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Models\Transactions;
use App\Models\Tracker;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Entry extends Model
{
protected $fillable = [
'tracker_id',
'date',
'quantity',
'unit_price',
'total_cost',
];
protected function casts(): array
{
return [
'date' => 'date',
'quantity' => 'decimal:6',
'unit_price' => 'decimal:4',
'total_cost' => 'decimal:2',
];
}
public function tracker(): BelongsTo
{
return $this->belongsTo(Tracker::class);
}
public static function totalQuantity(int $trackerId): float
{
return (float) static::where('tracker_id', $trackerId)->sum('quantity');
}
public static function totalCost(int $trackerId): float
{
return (float) static::where('tracker_id', $trackerId)->sum('total_cost');
}
public static function averageCostPerUnit(int $trackerId): float
{
$totalQuantity = static::totalQuantity($trackerId);
$totalCost = static::totalCost($trackerId);
return $totalQuantity > 0 ? $totalCost / $totalQuantity : 0;
}
}

View file

@ -1,52 +0,0 @@
<?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

@ -2,40 +2,23 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Models\Transactions\Purchase;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
/**
* @property int|null $asset_id
*/
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'asset_id',
'price_tracking_enabled',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
@ -46,27 +29,36 @@ protected function casts(): array
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'price_tracking_enabled' => 'boolean',
];
}
public function asset(): BelongsTo
public function tracker(): HasOne
{
return $this->belongsTo(Asset::class);
return $this->hasOne(Tracker::class);
}
public static function default(): self
{
return self::firstWhere('email', 'user@incr.local')
?? self::forceCreate([
'email' => 'user@incr.local',
'name' => 'Default User',
'password' => bcrypt(Str::random(32)),
]);
}
public function hasCompletedOnboarding(): bool
{
return $this->hasPurchases() && $this->hasMilestones();
return $this->hasEntries() && $this->hasMilestones();
}
public function hasPurchases(): bool
public function hasEntries(): bool
{
return Purchase::totalShares() > 0;
return (bool) $this->tracker?->entries()->exists();
}
public function hasMilestones(): bool
{
return Milestone::count() > 0;
return (bool) $this->tracker?->milestones()->exists();
}
}

View file

@ -10,9 +10,9 @@
"license": "MIT",
"require": {
"php": "^8.2",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"inertiajs/inertia-laravel": "^3.0",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0",
"tightenco/ziggy": "^2.4"
},
"require-dev": {
@ -22,7 +22,7 @@
"laravel/sail": "^1.43",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
"phpunit/phpunit": "^12.0"
},
"autoload": {
"psr-4": {

8516
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
<?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
{
Schema::create('trackers', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
$table->string('label');
$table->string('unit');
$table->boolean('price_tracking_enabled')->default(false);
$table->timestamps();
});
// Migrate existing users: create one tracker per user from their current asset_id + price_tracking_enabled
DB::table('users')->orderBy('id')->each(function (object $user) {
DB::table('trackers')->insert([
'user_id' => $user->id,
'asset_id' => $user->asset_id,
'label' => 'Portfolio',
'unit' => 'shares',
'price_tracking_enabled' => $user->price_tracking_enabled ?? false,
'created_at' => now(),
'updated_at' => now(),
]);
});
}
public function down(): void
{
// Restore asset_id and price_tracking_enabled back onto users before dropping trackers
DB::table('trackers')->orderBy('id')->each(function (object $tracker) {
DB::table('users')
->where('id', $tracker->user_id)
->update([
'asset_id' => $tracker->asset_id,
'price_tracking_enabled' => $tracker->price_tracking_enabled,
]);
});
Schema::dropIfExists('trackers');
}
};

View file

@ -0,0 +1,50 @@
<?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

@ -0,0 +1,44 @@
<?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
{
Schema::table('milestones', function (Blueprint $table) {
$table->foreignId('tracker_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
});
// Backfill tracker_id on milestones
$trackerId = DB::table('trackers')->value('id');
if ($trackerId) {
DB::table('milestones')->update(['tracker_id' => $trackerId]);
}
Schema::table('milestones', function (Blueprint $table) {
$table->unsignedBigInteger('tracker_id')->nullable(false)->change();
});
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['asset_id']);
$table->dropColumn(['asset_id', 'price_tracking_enabled']);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
$table->boolean('price_tracking_enabled')->default(false);
});
Schema::table('milestones', function (Blueprint $table) {
$table->dropForeign(['tracker_id']);
$table->dropColumn('tracker_id');
});
}
};

View file

@ -4,6 +4,12 @@ server {
root /var/www/html/public;
index index.php index.html;
server_tokens off;
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "SAMEORIGIN";
add_header Referrer-Policy "strict-origin-when-cross-origin";
location / {
try_files $uri $uri/ /index.php?$query_string;
}

View file

@ -10,7 +10,7 @@ fi
# Wait for database to be ready
echo "Waiting for database..."
until php artisan tinker --execute="DB::connection()->getPdo();" 2>/dev/null; do
until mysql -h"${DB_HOST:-db}" -u"${DB_USERNAME:-incr_user}" -p"${DB_PASSWORD}" -e "SELECT 1" >/dev/null 2>&1; do
echo "Database not ready, waiting..."
sleep 2
done

View file

@ -1,6 +1,6 @@
[supervisord]
nodaemon=true
user=root
user=www-data
[program:nginx]
command=nginx -g "daemon off;"

4867
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.13.5",
"eslint": "^9.17.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^5.1.0",
@ -24,7 +24,7 @@
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.0.0",
"@inertiajs/react": "^3.0.3",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
@ -41,20 +41,20 @@
"@tailwindcss/vite": "^4.0.6",
"@types/react": "^19.0.3",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^6.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"globals": "^15.14.0",
"laravel-vite-plugin": "^1.0",
"lucide-react": "^0.475.0",
"laravel-vite-plugin": "^3.1.0",
"lucide-react": "^1.14.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2",
"vite": "^6.0"
"typescript": "^6.0.3",
"vite": "^8.0.10"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",

View file

@ -19,7 +19,7 @@ interface AssetSetupFormProps {
}
export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormProps) {
const { data, setData, post, processing, errors } = useForm<AssetFormData>({
const { data, setData, patch, processing, errors } = useForm<AssetFormData>({
symbol: '',
full_name: '',
});
@ -28,13 +28,13 @@ export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormPr
useEffect(() => {
const fetchCurrentAsset = async () => {
try {
const response = await fetch('/assets/current');
const response = await fetch('/tracker');
if (response.ok) {
const assetData = await response.json();
if (assetData.asset) {
const tracker = await response.json();
if (tracker?.asset) {
setData({
symbol: assetData.asset.symbol || '',
full_name: assetData.asset.full_name || '',
symbol: tracker.asset.symbol || '',
full_name: tracker.asset.full_name || '',
});
}
}
@ -57,7 +57,7 @@ export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormPr
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('assets.set-current'), {
patch(route('tracker.update'), {
onSuccess: () => {
if (onSuccess) onSuccess();
},

View file

@ -1,11 +1,12 @@
import AddEntryForm from '@/components/Transactions/AddEntryForm';
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
import { cn } from '@/lib/utils';
import ComponentTitle from '@/components/ui/ComponentTitle';
interface InlineFormProps {
type: 'purchase' | 'milestone' | 'price' | null;
unit?: string;
priceTrackingEnabled?: boolean;
onClose: () => void;
onPurchaseSuccess?: () => void;
onMilestoneSuccess?: () => void;
@ -15,31 +16,30 @@ interface InlineFormProps {
export default function InlineForm({
type,
unit = 'units',
priceTrackingEnabled = false,
onClose,
onPurchaseSuccess,
onMilestoneSuccess,
onPriceSuccess,
className
className,
}: InlineFormProps) {
if (!type) return null;
const title = type === 'purchase' ? 'ADD PURCHASE' : type === 'milestone' ? 'ADD MILESTONE' : 'UPDATE PRICE';
return (
<div
className={cn(
"bg-black p-8",
"transition-all duration-300",
className
'bg-black p-8',
'transition-all duration-300',
className,
)}
>
{/* Header */}
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
{/* Form Content */}
<div className="flex justify-center">
{type === 'purchase' ? (
<AddPurchaseForm
<AddEntryForm
unit={unit}
priceTrackingEnabled={priceTrackingEnabled}
onSuccess={() => {
if (onPurchaseSuccess) onPurchaseSuccess();
onClose();

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
interface LedDisplayProps {
value: number;
unit?: string;
className?: string;
animate?: boolean;
onClick?: () => void;
@ -10,6 +11,7 @@ interface LedDisplayProps {
export default function LedDisplay({
value,
unit,
className,
onClick
}: LedDisplayProps) {
@ -55,6 +57,11 @@ export default function LedDisplay({
{formattedValue}
</div>
</div>
{unit && (
<div className="text-red-500/50 font-mono text-sm uppercase tracking-widest mt-2">
{unit}
</div>
)}
</div>
);
}

View file

@ -7,28 +7,26 @@ interface Milestone {
}
interface ProgressBarProps {
currentShares: number;
currentQuantity: number;
milestones: Milestone[];
selectedMilestoneIndex?: number;
className?: string;
onClick?: () => void;
}
export default function ProgressBar({
currentShares,
export default function ProgressBar({
currentQuantity,
milestones,
selectedMilestoneIndex = 0,
className,
onClick
}: ProgressBarProps) {
// Get the selected milestone for progress calculation
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
? milestones[selectedMilestoneIndex]
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
? milestones[selectedMilestoneIndex]
: null;
// Calculate progress percentage
const progressPercentage = selectedMilestone
? Math.min((currentShares / selectedMilestone.target) * 100, 100)
const progressPercentage = selectedMilestone
? Math.min((currentQuantity / selectedMilestone.target) * 100, 100)
: 0;
return (
<div

View file

@ -19,6 +19,7 @@ interface StatsBoxProps {
profitLoss?: number;
profitLossPercentage?: number;
};
unit?: string;
milestones?: Milestone[];
selectedMilestoneIndex?: number;
onMilestoneSelect?: (index: number) => void;
@ -32,6 +33,7 @@ interface StatsBoxProps {
export default function StatsBox({
stats,
unit = 'units',
milestones = [],
selectedMilestoneIndex = 0,
onMilestoneSelect,
@ -107,7 +109,7 @@ export default function StatsBox({
}}
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
ADD ENTRY
</button>
)}
{onAddMilestone && (
@ -157,7 +159,7 @@ export default function StatsBox({
<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">{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>

View file

@ -0,0 +1,161 @@
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;
}
export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps) {
const [priceTracking, setPriceTracking] = useState(false);
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) => {
e.preventDefault();
post(route('tracker.store'), {
onSuccess: () => {
onSuccess(priceTracking);
},
});
};
return (
<div className="w-full">
<div className="space-y-4">
<ComponentTitle>SET UP YOUR TRACKER</ComponentTitle>
<p className="text-sm text-red-400/60 font-mono">
[SYSTEM] What are you tracking?
</p>
<form onSubmit={submit} className="space-y-4">
<div>
<Label htmlFor="label" className="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Tracker Name
</Label>
<Input
id="label"
type="text"
placeholder="My Portfolio"
value={data.label}
onChange={(e) => setData('label', 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">
[REQUIRED] e.g. "My Portfolio", "Books Read", "KM Run"
</p>
<InputError message={errors.label} />
</div>
<div>
<Label htmlFor="unit" className="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Unit
</Label>
<Input
id="unit"
type="text"
placeholder="shares"
value={data.unit}
onChange={(e) => setData('unit', 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">
[REQUIRED] e.g. "shares", "books", "km"
</p>
<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)}
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" />}
[INITIALIZE]
</Button>
</form>
</div>
</div>
);
}

View file

@ -1,46 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import AssetSetupForm from '@/components/Assets/AssetSetupForm';
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
import AddEntryForm from '@/components/Transactions/AddEntryForm';
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
type TrackerType = 'simple' | 'asset';
function TrackerTypeSelector({ onSelect }: { onSelect: (type: TrackerType) => void }) {
return (
<div className="space-y-6">
<p className="text-red-400 font-mono text-sm uppercase tracking-wider">
[SELECT] What do you want to track?
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => onSelect('simple')}
className="text-left border-2 border-red-500/50 bg-black p-6 hover:bg-red-950/30 hover:border-red-400 hover:shadow-[0_0_20px_rgba(239,68,68,0.3)] transition-all"
>
<span className="block text-red-400 font-mono text-lg font-bold uppercase tracking-wider mb-3">
[01] Simple counter
</span>
<p className="text-red-400/60 font-mono text-xs">
Track anything you accumulate no price tracking, no asset setup.
</p>
</button>
<button
onClick={() => onSelect('asset')}
className="text-left border-2 border-red-500/50 bg-black p-6 hover:bg-red-950/30 hover:border-red-400 hover:shadow-[0_0_20px_rgba(239,68,68,0.3)] transition-all"
>
<span className="block text-red-400 font-mono text-lg font-bold uppercase tracking-wider mb-3">
[02] Asset tracker
</span>
<p className="text-red-400/60 font-mono text-xs">
Track holdings with price tracking and P&amp;L.
</p>
</button>
</div>
</div>
);
}
import CreateTrackerStep from '@/components/Onboarding/CreateTrackerStep';
function PriceTrackingStep({ onEnable, onSkip }: { onEnable: () => void; onSkip?: () => void }) {
const [enabled, setEnabled] = useState(false);
@ -70,7 +33,7 @@ function PriceTrackingStep({ onEnable, onSkip }: { onEnable: () => void; onSkip?
{!enabled && (
<button
onClick={onSkip}
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
@ -89,14 +52,13 @@ interface OnboardingStep {
}
const ASSET_STEPS: OnboardingStep[] = [
{ id: 'asset', title: 'SET ASSET', description: 'Choose the asset you want to track', completed: false, required: true },
{ id: 'purchases', title: 'ADD PURCHASES', description: 'Enter your current holdings', completed: false, required: true },
{ 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[] = [
{ id: 'purchases', title: 'STARTING AMOUNT', description: 'Enter your starting amount', completed: false, required: true },
{ 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 },
];
@ -105,29 +67,27 @@ interface OnboardingFlowProps {
}
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const [trackerType, setTrackerType] = useState<TrackerType | null>(null);
const [trackerCreated, setTrackerCreated] = useState(false);
const [priceTracking, setPriceTracking] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [steps, setSteps] = useState<OnboardingStep[]>([]);
const checkOnboardingStatus = useCallback(async (currentSteps: OnboardingStep[]) => {
try {
const [purchaseData, milestonesData, assetData, priceData] = await Promise.all([
fetch('/purchases/summary').then(r => r.json()),
const [entriesData, milestonesData, priceData] = await Promise.all([
fetch('/entries/summary').then(r => r.json()),
fetch('/milestones').then(r => r.json()),
fetch('/assets/current').then(r => r.json()),
fetch('/pricing/current').then(r => r.json()),
]);
const hasPurchases = purchaseData.total_shares > 0;
const hasEntries = entriesData.total_quantity > 0;
const hasMilestones = milestonesData.length > 0;
const hasAsset = !!assetData.asset;
const hasPrice = !!priceData.current_price;
const freshSteps = currentSteps.map(step => ({
...step,
completed:
(step.id === 'asset' && hasAsset) ||
(step.id === 'purchases' && hasPurchases) ||
(step.id === 'entries' && hasEntries) ||
(step.id === 'milestones' && hasMilestones) ||
(step.id === 'price' && hasPrice),
}));
@ -147,15 +107,18 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
}, [onComplete]);
useEffect(() => {
if (trackerType === null) {
return;
}
if (!trackerCreated) return;
const initialSteps = trackerType === 'simple' ? SIMPLE_STEPS : ASSET_STEPS;
const initialSteps = priceTracking ? ASSET_STEPS : SIMPLE_STEPS;
setSteps(initialSteps);
setCurrentStep(0);
checkOnboardingStatus(initialSteps);
}, [trackerType, checkOnboardingStatus]);
}, [trackerCreated, priceTracking, checkOnboardingStatus]);
const handleTrackerCreated = (withPriceTracking: boolean) => {
setPriceTracking(withPriceTracking);
setTrackerCreated(true);
};
const handleStepComplete = async () => {
const updatedSteps = steps.map((step, index) =>
@ -171,15 +134,11 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const renderStepContent = () => {
const step = steps[currentStep];
if (!step) {
return null;
}
if (!step) return null;
switch (step.id) {
case 'asset':
return <AssetSetupForm onSuccess={handleStepComplete} />;
case 'purchases':
return <AddPurchaseForm onSuccess={handleStepComplete} />;
case 'entries':
return <AddEntryForm onSuccess={handleStepComplete} priceTrackingEnabled={priceTracking} />;
case 'milestones':
return <AddMilestoneForm onSuccess={handleStepComplete} />;
case 'price':
@ -198,13 +157,13 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
[SYSTEM] ONBOARDING SEQUENCE
</h1>
<p className="text-red-400/60 font-mono text-sm">
{trackerType === null ? 'Choose how you want to track' : 'Set up your tracker'}
{!trackerCreated ? 'Set up your tracker' : 'Configure your tracker'}
</p>
</div>
{trackerType === null ? (
{!trackerCreated ? (
<div className="border border-red-500/30 bg-black/50 p-6">
<TrackerTypeSelector onSelect={setTrackerType} />
<CreateTrackerStep onSuccess={handleTrackerCreated} />
</div>
) : (
<>

View file

@ -1,53 +0,0 @@
import { Button } from '@/components/ui/button';
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';
export default function AppearanceToggleDropdown({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
const { appearance, updateAppearance } = useAppearance();
const getCurrentIcon = () => {
switch (appearance) {
case 'dark':
return <Moon className="h-5 w-5" />;
case 'light':
return <Sun className="h-5 w-5" />;
default:
return <Monitor className="h-5 w-5" />;
}
};
return (
<div className={className} {...props}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
{getCurrentIcon()}
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => updateAppearance('light')}>
<span className="flex items-center gap-2">
<Sun className="h-5 w-5" />
Light
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateAppearance('dark')}>
<span className="flex items-center gap-2">
<Moon className="h-5 w-5" />
Dark
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => updateAppearance('system')}>
<span className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
System
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -1,34 +0,0 @@
import { Appearance, useAppearance } from '@/hooks/use-appearance';
import { cn } from '@/lib/utils';
import { LucideIcon, Monitor, Moon, Sun } from 'lucide-react';
import { HTMLAttributes } from 'react';
export default function AppearanceToggleTab({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
const { appearance, updateAppearance } = useAppearance();
const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [
{ value: 'light', icon: Sun, label: 'Light' },
{ value: 'dark', icon: Moon, label: 'Dark' },
{ value: 'system', icon: Monitor, label: 'System' },
];
return (
<div className={cn('inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800', className)} {...props}>
{tabs.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => updateAppearance(value)}
className={cn(
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
appearance === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
)}
>
<Icon className="-ml-1 h-4 w-4" />
<span className="ml-1.5 text-sm">{label}</span>
</button>
))}
</div>
);
}

View file

@ -0,0 +1,181 @@
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, useState } from 'react';
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) {
const { data, setData, post, processing, errors, reset } = useForm<EntryFormData>({
date: new Date().toISOString().split('T')[0],
quantity: '',
unit_price: '',
total_cost: '',
});
const [currentHoldings, setCurrentHoldings] = useState<EntrySummary | null>(null);
useEffect(() => {
const fetchSummary = async () => {
try {
const response = await fetch('/entries/summary');
if (response.ok) {
const summary = await response.json();
setCurrentHoldings(summary);
}
} catch (error) {
console.error('Failed to fetch entry summary:', error);
}
};
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();
post(route('entries.store'), {
onSuccess: () => {
reset();
setData('date', new Date().toISOString().split('T')[0]);
if (onSuccess) onSuccess();
},
});
};
return (
<div className="w-full">
<div className="space-y-4">
<ComponentTitle>ADD ENTRY</ComponentTitle>
{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">
<div>
<Label htmlFor="date" className="text-red-400 font-mono text-xs uppercase tracking-wider">&gt; 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 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none transition-all glow-red"
/>
<InputError message={errors.date} />
</div>
<div>
<Label htmlFor="quantity" className="text-red-400 font-mono text-xs uppercase tracking-wider">
&gt; Quantity ({unit})
</Label>
<Input
id="quantity"
type="number"
step="0.000001"
min="0"
placeholder="1.234567"
value={data.quantity}
onChange={(e) => setData('quantity', 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.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"
disabled={processing}
className="flex-1 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" />}
[EXECUTE]
</Button>
{onCancel && (
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
>
[ABORT]
</Button>
)}
</div>
</form>
</div>
</div>
);
}

View file

@ -1,68 +0,0 @@
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import { type NavItem } from '@/types';
import { Link } from '@inertiajs/react';
import { type PropsWithChildren } from 'react';
const sidebarNavItems: NavItem[] = [
{
title: 'Profile',
href: '/settings/profile',
icon: null,
},
{
title: 'Password',
href: '/settings/password',
icon: null,
},
{
title: 'Appearance',
href: '/settings/appearance',
icon: null,
},
];
export default function SettingsLayout({ children }: PropsWithChildren) {
// When server-side rendering, we only render the layout on the client...
if (typeof window === 'undefined') {
return null;
}
const currentPath = window.location.pathname;
return (
<div className="px-4 py-6">
<Heading title="Settings" description="Manage your profile and account settings" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-y-0 lg:space-x-12">
<aside className="w-full max-w-xl lg:w-48">
<nav className="flex flex-col space-y-1 space-x-0">
{sidebarNavItems.map((item, index) => (
<Button
key={`${item.href}-${index}`}
size="sm"
variant="ghost"
asChild
className={cn('w-full justify-start', {
'bg-muted': currentPath === item.href,
})}
>
<Link href={item.href} prefetch>
{item.title}
</Link>
</Button>
))}
</nav>
</aside>
<Separator className="my-6 md:hidden" />
<div className="flex-1 md:max-w-2xl">
<section className="max-w-xl space-y-12">{children}</section>
</div>
</div>
</div>
);
}

View file

@ -1,60 +0,0 @@
// Components
import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import InputError from '@/components/InputError';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/auth-layout';
export default function ConfirmPassword() {
const { data, setData, post, processing, errors, reset } = useForm<Required<{ password: string }>>({
password: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('password.confirm'), {
onFinish: () => reset('password'),
});
};
return (
<AuthLayout
title="Confirm your password"
description="This is a secure area of the application. Please confirm your password before continuing."
>
<Head title="Confirm password" />
<form onSubmit={submit}>
<div className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
name="password"
placeholder="Password"
autoComplete="current-password"
value={data.password}
autoFocus
onChange={(e) => setData('password', e.target.value)}
/>
<InputError message={errors.password} />
</div>
<div className="flex items-center">
<Button className="w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Confirm password
</Button>
</div>
</div>
</form>
</AuthLayout>
);
}

View file

@ -1,63 +0,0 @@
// Components
import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
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';
import AuthLayout from '@/layouts/auth-layout';
export default function ForgotPassword({ status }: { status?: string }) {
const { data, setData, post, processing, errors } = useForm<Required<{ email: string }>>({
email: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('password.email'));
};
return (
<AuthLayout title="Forgot password" description="Enter your email to receive a password reset link">
<Head title="Forgot password" />
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
<div className="space-y-6">
<form onSubmit={submit}>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
autoComplete="off"
value={data.email}
autoFocus
onChange={(e) => setData('email', e.target.value)}
placeholder="email@example.com"
/>
<InputError message={errors.email} />
</div>
<div className="my-6 flex items-center justify-start">
<Button className="w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Email password reset link
</Button>
</div>
</form>
<div className="space-x-1 text-center text-sm text-muted-foreground">
<span>Or, return to</span>
<TextLink href={route('login')}>log in</TextLink>
</div>
</div>
</AuthLayout>
);
}

View file

@ -1,110 +0,0 @@
import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
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';
import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/auth-layout';
type LoginForm = {
email: string;
password: string;
remember: boolean;
};
interface LoginProps {
status?: string;
canResetPassword: boolean;
}
export default function Login({ status, canResetPassword }: LoginProps) {
const { data, setData, post, processing, errors, reset } = useForm<Required<LoginForm>>({
email: '',
password: '',
remember: false,
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('login'), {
onFinish: () => reset('password'),
});
};
return (
<AuthLayout title="Log in to your account" description="Enter your email and password below to log in">
<Head title="Log in" />
<form className="flex flex-col gap-6" onSubmit={submit}>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
required
autoFocus
tabIndex={1}
autoComplete="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
placeholder="email@example.com"
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
{canResetPassword && (
<TextLink href={route('password.request')} className="ml-auto text-sm" tabIndex={5}>
Forgot password?
</TextLink>
)}
</div>
<Input
id="password"
type="password"
required
tabIndex={2}
autoComplete="current-password"
value={data.password}
onChange={(e) => setData('password', e.target.value)}
placeholder="Password"
/>
<InputError message={errors.password} />
</div>
<div className="flex items-center space-x-3">
<Checkbox
id="remember"
name="remember"
checked={data.remember}
onClick={() => setData('remember', !data.remember)}
tabIndex={3}
/>
<Label htmlFor="remember">Remember me</Label>
</div>
<Button type="submit" className="mt-4 w-full" tabIndex={4} disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Log in
</Button>
</div>
<div className="text-center text-sm text-muted-foreground">
Don't have an account?{' '}
<TextLink href={route('register')} tabIndex={5}>
Sign up
</TextLink>
</div>
</form>
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
</AuthLayout>
);
}

View file

@ -1,119 +0,0 @@
import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
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';
import AuthLayout from '@/layouts/auth-layout';
type RegisterForm = {
name: string;
email: string;
password: string;
password_confirmation: string;
};
export default function Register() {
const { data, setData, post, processing, errors, reset } = useForm<Required<RegisterForm>>({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('register'), {
onFinish: () => reset('password', 'password_confirmation'),
});
};
return (
<AuthLayout title="Create an account" description="Enter your details below to create your account">
<Head title="Register" />
<form className="flex flex-col gap-6" onSubmit={submit}>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
required
autoFocus
tabIndex={1}
autoComplete="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
disabled={processing}
placeholder="Full name"
/>
<InputError message={errors.name} className="mt-2" />
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
required
tabIndex={2}
autoComplete="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
disabled={processing}
placeholder="email@example.com"
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
required
tabIndex={3}
autoComplete="new-password"
value={data.password}
onChange={(e) => setData('password', e.target.value)}
disabled={processing}
placeholder="Password"
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">Confirm password</Label>
<Input
id="password_confirmation"
type="password"
required
tabIndex={4}
autoComplete="new-password"
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
disabled={processing}
placeholder="Confirm password"
/>
<InputError message={errors.password_confirmation} />
</div>
<Button type="submit" className="mt-2 w-full" tabIndex={5} disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Create account
</Button>
</div>
<div className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<TextLink href={route('login')} tabIndex={6}>
Log in
</TextLink>
</div>
</form>
</AuthLayout>
);
}

View file

@ -1,98 +0,0 @@
import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import InputError from '@/components/InputError';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/auth-layout';
interface ResetPasswordProps {
token: string;
email: string;
}
type ResetPasswordForm = {
token: string;
email: string;
password: string;
password_confirmation: string;
};
export default function ResetPassword({ token, email }: ResetPasswordProps) {
const { data, setData, post, processing, errors, reset } = useForm<Required<ResetPasswordForm>>({
token: token,
email: email,
password: '',
password_confirmation: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('password.store'), {
onFinish: () => reset('password', 'password_confirmation'),
});
};
return (
<AuthLayout title="Reset password" description="Please enter your new password below">
<Head title="Reset password" />
<form onSubmit={submit}>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
name="email"
autoComplete="email"
value={data.email}
className="mt-1 block w-full"
readOnly
onChange={(e) => setData('email', e.target.value)}
/>
<InputError message={errors.email} className="mt-2" />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
name="password"
autoComplete="new-password"
value={data.password}
className="mt-1 block w-full"
autoFocus
onChange={(e) => setData('password', e.target.value)}
placeholder="Password"
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">Confirm password</Label>
<Input
id="password_confirmation"
type="password"
name="password_confirmation"
autoComplete="new-password"
value={data.password_confirmation}
className="mt-1 block w-full"
onChange={(e) => setData('password_confirmation', e.target.value)}
placeholder="Confirm password"
/>
<InputError message={errors.password_confirmation} className="mt-2" />
</div>
<Button type="submit" className="mt-4 w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Reset password
</Button>
</div>
</form>
</AuthLayout>
);
}

View file

@ -1,41 +0,0 @@
// Components
import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import TextLink from '@/components/TextLink';
import { Button } from '@/components/ui/button';
import AuthLayout from '@/layouts/auth-layout';
export default function VerifyEmail({ status }: { status?: string }) {
const { post, processing } = useForm({});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('verification.send'));
};
return (
<AuthLayout title="Verify email" description="Please verify your email address by clicking on the link we just emailed to you.">
<Head title="Email verification" />
{status === 'verification-link-sent' && (
<div className="mb-4 text-center text-sm font-medium text-green-600">
A new verification link has been sent to the email address you provided during registration.
</div>
)}
<form onSubmit={submit} className="space-y-6 text-center">
<Button disabled={processing} variant="secondary">
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Resend verification email
</Button>
<TextLink href={route('logout')} method="post" className="mx-auto block text-sm">
Log out
</TextLink>
</form>
</AuthLayout>
);
}

View file

@ -5,7 +5,7 @@ import StatsBox from '@/components/Display/StatsBox';
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
import TerminalSpinner from '@/components/ui/TerminalSpinner';
import { Head } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
interface PurchaseSummary {
total_shares: number;
@ -23,6 +23,20 @@ interface Milestone {
created_at: string;
}
interface TrackerAsset {
id: number;
symbol: string;
full_name: string | null;
}
interface Tracker {
id: number;
label: string;
unit: string;
price_tracking_enabled: boolean;
asset: TrackerAsset | null;
}
export default function Dashboard() {
const [purchaseData, setPurchaseData] = useState<PurchaseSummary>({
total_shares: 0,
@ -41,23 +55,32 @@ export default function Dashboard() {
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | 'price' | null>(null);
const [loading, setLoading] = useState(true);
const [needsOnboarding, setNeedsOnboarding] = useState(false);
const [currentAsset, setCurrentAsset] = useState<any>(null);
const [tracker, setTracker] = useState<Tracker | null>(null);
const [currentAsset, setCurrentAsset] = useState<TrackerAsset | null>(null);
const [priceTrackingEnabled, setPriceTrackingEnabled] = useState(false);
// Fetch purchase summary, current price, milestones, and check onboarding
// Fetch entry summary, current price, milestones, and check onboarding
useEffect(() => {
const fetchData = async () => {
try {
const [purchaseResponse, priceResponse, milestonesResponse, assetResponse] = await Promise.all([
fetch('/purchases/summary'),
const [entriesResponse, priceResponse, milestonesResponse, trackerResponse] = await Promise.all([
fetch('/entries/summary'),
fetch('/pricing/current'),
fetch('/milestones'),
fetch('/assets/current'),
fetch('/tracker'),
]);
if (purchaseResponse.ok) {
const purchases = await purchaseResponse.json();
setPurchaseData(purchases);
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) {
@ -68,16 +91,17 @@ export default function Dashboard() {
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
milestonesCount = milestonesData.length;
}
if (assetResponse.ok) {
const assetData = await assetResponse.json();
setCurrentAsset(assetData.asset);
setPriceTrackingEnabled(assetData.price_tracking_enabled ?? false);
if (trackerResponse.ok) {
const trackerData = await trackerResponse.json();
setTracker(trackerData);
setCurrentAsset(trackerData?.asset ?? null);
setPriceTrackingEnabled(trackerData?.price_tracking_enabled ?? false);
}
// Check if onboarding is needed after all data is loaded
await checkOnboardingStatus();
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
@ -88,43 +112,20 @@ export default function Dashboard() {
fetchData();
}, []);
// Check if user needs onboarding
const checkOnboardingStatus = async () => {
try {
const [assetResponse, purchaseResponse, milestonesResponse] = await Promise.all([
fetch('/assets/current'),
fetch('/purchases/summary'),
fetch('/milestones'),
]);
const assetData = await assetResponse.json();
const purchaseData = await purchaseResponse.json();
const milestonesData = await milestonesResponse.json();
const hasAsset = !!assetData.asset;
const hasPurchases = purchaseData.total_shares > 0;
const hasMilestones = milestonesData.length > 0;
// User needs onboarding if any required step is missing
const needsOnboarding = !hasPurchases || !hasMilestones;
setNeedsOnboarding(needsOnboarding);
} catch (error) {
console.error('Failed to check onboarding status:', error);
// If we can't check, assume onboarding is needed
setNeedsOnboarding(true);
}
};
// Refresh data after successful purchase
// Refresh data after successful entry
const handlePurchaseSuccess = async () => {
try {
const purchaseResponse = await fetch('/purchases/summary');
if (purchaseResponse.ok) {
const purchases = await purchaseResponse.json();
setPurchaseData(purchases);
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,
});
}
} catch (error) {
console.error('Failed to refresh purchase data:', error);
console.error('Failed to refresh entry data:', error);
}
};
@ -210,21 +211,25 @@ export default function Dashboard() {
};
// Handle onboarding completion
const handleOnboardingComplete = async () => {
// Refresh all data and check onboarding status
await checkOnboardingStatus();
// Refresh individual data sets
const [purchaseResponse, priceResponse, milestonesResponse, assetResponse] = await Promise.all([
fetch('/purchases/summary'),
const handleOnboardingComplete = useCallback(async () => {
const [entriesResponse, priceResponse, milestonesResponse, trackerResponse] = await Promise.all([
fetch('/entries/summary'),
fetch('/pricing/current'),
fetch('/milestones'),
fetch('/assets/current'),
fetch('/tracker'),
]);
if (purchaseResponse.ok) {
const purchases = await purchaseResponse.json();
setPurchaseData(purchases);
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) {
@ -235,14 +240,17 @@ export default function Dashboard() {
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
milestonesCount = milestonesData.length;
}
if (assetResponse.ok) {
const assetData = await assetResponse.json();
setCurrentAsset(assetData.asset);
setPriceTrackingEnabled(assetData.price_tracking_enabled ?? false);
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) {
@ -265,6 +273,7 @@ export default function Dashboard() {
<div className="pt-32">
<LedDisplay
value={purchaseData.total_shares}
unit={tracker?.unit}
onClick={handleLedClick}
/>
</div>
@ -272,7 +281,7 @@ export default function Dashboard() {
{/* Box 2: Progress Bar (toggleable) */}
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
<ProgressBar
currentShares={purchaseData.total_shares}
currentQuantity={purchaseData.total_shares}
milestones={milestones}
selectedMilestoneIndex={selectedMilestoneIndex}
onClick={handleProgressClick}
@ -283,6 +292,7 @@ export default function Dashboard() {
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
<StatsBox
stats={statsData}
unit={tracker?.unit}
milestones={milestones}
selectedMilestoneIndex={selectedMilestoneIndex}
onMilestoneSelect={handleMilestoneSelect}
@ -298,6 +308,8 @@ export default function Dashboard() {
<div style={{ display: activeForm && showProgressBar && showStatsBox ? 'block' : 'none' }}>
<InlineForm
type={activeForm}
unit={tracker?.unit}
priceTrackingEnabled={priceTrackingEnabled}
onClose={() => setActiveForm(null)}
onPurchaseSuccess={handlePurchaseSuccess}
onMilestoneSuccess={handleMilestoneSuccess}

View file

@ -1,30 +0,0 @@
import { Head } from '@inertiajs/react';
import AppearanceTabs from '@/components/Settings/AppearanceTabs';
import HeadingSmall from '@/components/HeadingSmall';
import { type BreadcrumbItem } from '@/types';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Appearance settings',
href: '/settings/appearance',
},
];
export default function Appearance() {
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Appearance settings" />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall title="Appearance settings" description="Update your account's appearance settings" />
<AppearanceTabs />
</div>
</SettingsLayout>
</AppLayout>
);
}

View file

@ -1,128 +0,0 @@
import InputError from '@/components/InputError';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { type BreadcrumbItem } from '@/types';
import { Transition } from '@headlessui/react';
import { Head, useForm } from '@inertiajs/react';
import { FormEventHandler, useRef } from 'react';
import HeadingSmall from '@/components/HeadingSmall';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Password settings',
href: '/settings/password',
},
];
export default function Password() {
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
const { data, setData, errors, put, reset, processing, recentlySuccessful } = useForm({
current_password: '',
password: '',
password_confirmation: '',
});
const updatePassword: FormEventHandler = (e) => {
e.preventDefault();
put(route('password.update'), {
preserveScroll: true,
onSuccess: () => reset(),
onError: (errors) => {
if (errors.password) {
reset('password', 'password_confirmation');
passwordInput.current?.focus();
}
if (errors.current_password) {
reset('current_password');
currentPasswordInput.current?.focus();
}
},
});
};
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Password settings" />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall title="Update password" description="Ensure your account is using a long, random password to stay secure" />
<form onSubmit={updatePassword} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="current_password">Current password</Label>
<Input
id="current_password"
ref={currentPasswordInput}
value={data.current_password}
onChange={(e) => setData('current_password', e.target.value)}
type="password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
/>
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<Input
id="password"
ref={passwordInput}
value={data.password}
onChange={(e) => setData('password', e.target.value)}
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">Confirm password</Label>
<Input
id="password_confirmation"
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
/>
<InputError message={errors.password_confirmation} />
</div>
<div className="flex items-center gap-4">
<Button disabled={processing}>Save password</Button>
<Transition
show={recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-neutral-600">Saved</p>
</Transition>
</div>
</form>
</div>
</SettingsLayout>
</AppLayout>
);
}

View file

@ -1,127 +0,0 @@
import { type BreadcrumbItem, type SharedData } from '@/types';
import { Transition } from '@headlessui/react';
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { FormEventHandler } from 'react';
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';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Profile settings',
href: '/settings/profile',
},
];
type ProfileForm = {
name: string;
email: string;
};
export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail: boolean; status?: string }) {
const { auth } = usePage<SharedData>().props;
const { data, setData, patch, errors, processing, recentlySuccessful } = useForm<Required<ProfileForm>>({
name: auth.user.name,
email: auth.user.email,
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
patch(route('profile.update'), {
preserveScroll: true,
});
};
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Profile settings" />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall title="Profile information" description="Update your name and email address" />
<form onSubmit={submit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="mt-1 block w-full"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
required
autoComplete="name"
placeholder="Full name"
/>
<InputError className="mt-2" message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
className="mt-1 block w-full"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
required
autoComplete="username"
placeholder="Email address"
/>
<InputError className="mt-2" message={errors.email} />
</div>
{mustVerifyEmail && auth.user.email_verified_at === null && (
<div>
<p className="-mt-4 text-sm text-muted-foreground">
Your email address is unverified.{' '}
<Link
href={route('verification.send')}
method="post"
as="button"
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to resend the verification email.
</Link>
</p>
{status === 'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600">
A new verification link has been sent to your email address.
</div>
)}
</div>
)}
<div className="flex items-center gap-4">
<Button disabled={processing}>Save</Button>
<Transition
show={recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-neutral-600">Saved</p>
</Transition>
</div>
</form>
</div>
<DeleteUser />
</SettingsLayout>
</AppLayout>
);
}

View file

@ -1,56 +1,8 @@
<?php
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\ConfirmablePasswordController;
use App\Http\Controllers\Auth\EmailVerificationNotificationController;
use App\Http\Controllers\Auth\EmailVerificationPromptController;
use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::get('register', [RegisteredUserController::class, 'create'])
->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);
Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login');
Route::post('login', [AuthenticatedSessionController::class, 'store']);
Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
->name('password.request');
Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
->name('password.email');
Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
->name('password.reset');
Route::post('reset-password', [NewPasswordController::class, 'store'])
->name('password.store');
});
Route::middleware('auth')->group(function () {
Route::get('verify-email', EmailVerificationPromptController::class)
->name('verification.notice');
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
->middleware(['signed', 'throttle:6,1'])
->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
->middleware('throttle:6,1')
->name('verification.send');
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
->name('password.confirm');
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->name('logout');
});
// First-run setup only — gated by User::exists() in the controller
Route::get('register', [RegisteredUserController::class, 'create'])->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);

View file

@ -1,21 +0,0 @@
<?php
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::middleware('auth')->group(function () {
Route::redirect('settings', '/settings/profile');
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('settings/password', [PasswordController::class, 'edit'])->name('password.edit');
Route::put('settings/password', [PasswordController::class, 'update'])->name('password.update');
Route::get('settings/appearance', function () {
return Inertia::render('settings/appearance');
})->name('appearance');
});

View file

@ -3,7 +3,8 @@
use App\Http\Controllers\AssetController;
use App\Http\Controllers\Milestones\MilestoneController;
use App\Http\Controllers\Pricing\PricingController;
use App\Http\Controllers\Transactions\PurchaseController;
use App\Http\Controllers\TrackerController;
use App\Http\Controllers\Transactions\EntryController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@ -15,22 +16,25 @@
return Inertia::render('dashboard');
})->name('dashboard');
// Tracker routes
Route::get('/tracker', [TrackerController::class, 'show'])->name('tracker.show');
Route::post('/tracker', [TrackerController::class, 'store'])->name('tracker.store');
Route::patch('/tracker', [TrackerController::class, 'update'])->name('tracker.update');
// Asset routes
Route::prefix('assets')->name('assets.')->group(function () {
Route::get('/', [AssetController::class, 'index'])->name('index');
Route::post('/', [AssetController::class, 'store'])->name('store');
Route::get('/current', [AssetController::class, 'current'])->name('current');
Route::post('/set-current', [AssetController::class, 'setCurrent'])->name('set-current');
Route::get('/search', [AssetController::class, 'search'])->name('search');
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
});
// 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');
// Entry routes (replaces purchases)
Route::prefix('entries')->name('entries.')->group(function () {
Route::get('/', [EntryController::class, 'index'])->name('index');
Route::post('/', [EntryController::class, 'store'])->name('store');
Route::get('/summary', [EntryController::class, 'summary'])->name('summary');
Route::delete('/{entry}', [EntryController::class, 'destroy'])->name('destroy');
});
// Pricing routes
@ -47,5 +51,4 @@
Route::post('/', [MilestoneController::class, 'store'])->name('store');
});
require __DIR__.'/settings.php';
require __DIR__.'/auth.php';

View file

@ -3,6 +3,8 @@
namespace Tests\Feature;
use App\Models\Milestone;
use App\Models\Tracker;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -10,9 +12,21 @@ class MilestoneTest extends TestCase
{
use RefreshDatabase;
private function tracker(): Tracker
{
return Tracker::create([
'user_id' => User::default()->id,
'label' => 'Test',
'unit' => 'units',
]);
}
public function test_can_create_milestone(): void
{
$tracker = $this->tracker();
$milestone = Milestone::create([
'tracker_id' => $tracker->id,
'target' => 1500,
'description' => 'First milestone',
]);
@ -28,9 +42,9 @@ public function test_can_create_milestone(): void
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']);
$tracker = $this->tracker();
Milestone::create(['tracker_id' => $tracker->id, 'target' => 1500, 'description' => 'First milestone']);
Milestone::create(['tracker_id' => $tracker->id, 'target' => 3000, 'description' => 'Second milestone']);
$response = $this->get('/milestones');
@ -44,10 +58,10 @@ public function test_can_fetch_milestones_via_api(): void
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']);
$tracker = $this->tracker();
Milestone::create(['tracker_id' => $tracker->id, 'target' => 3000, 'description' => 'Third']);
Milestone::create(['tracker_id' => $tracker->id, 'target' => 1000, 'description' => 'First']);
Milestone::create(['tracker_id' => $tracker->id, 'target' => 2000, 'description' => 'Second']);
$response = $this->get('/milestones');