Compare commits

...

38 commits

Author SHA1 Message Date
bdfb7c031c Merge pull request 'v0.3.0' (#45) from release/0.3.0 into main
Some checks failed
Build and Push Docker Image / build (push) Failing after 1m17s
Reviewed-on: #45
2026-05-09 15:08:37 +02:00
ab957d34cf 24 - Update CI image tags to lvl0/incr after repo move
Some checks failed
CI / ci (push) Successful in 29m26s
CI / build (push) Successful in 1m38s
CI / ci (pull_request) Failing after 31m46s
CI / build (pull_request) Successful in 1m13s
2026-05-09 10:57:02 +02:00
bace06d993 44 - Hide price tracking UI and functionality for v0.3.0 2026-05-09 10:51:40 +02:00
195e316da5 31+32 - Fix entries migration: nullable unit_price/total_cost, consolidate rename migration into source 2026-05-04 21:54:51 +02:00
cdb1e268e4 fix - TrackerController::show return {exists,tracker} to avoid null/{} ambiguity in JS 2026-05-03 02:38:38 +02:00
c73b634b44 fix - Onboarding: skip step 1 if tracker exists, treat 409 as success in CreateTrackerStep 2026-05-03 02:30:55 +02:00
14f5d34775 fix - CreateTrackerStep use plain fetch to avoid Inertia page reload, add csrf-token meta 2026-05-03 01:38:02 +02:00
e93ce7b342 fix - TrackerController store/update return back() for Inertia compatibility 2026-05-03 01:30:57 +02:00
fa69d78afe fix - Inertia 3: remove page component from @vite directive, fix useCallback hook order violation 2026-05-03 00:21:15 +02:00
221a0f879d 42 - Extract todayISO() to utils, delete dead AddPurchaseForm
All checks were successful
CI / ci (push) Successful in 13m17s
CI / build (push) Successful in 24s
2026-05-02 20:43:41 +02:00
40f0e687f2 41 - Unify InlineForm callbacks to onSuccess(type) 2026-05-02 20:32:53 +02:00
941cd60680 40 - Centralise Milestone/Tracker/TrackerAsset interfaces to types/domain.ts 2026-05-02 20:28:43 +02:00
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
fe3711e57c 39 - Add multi-arch build pipeline, fix prod Dockerfile, add .dockerignore
All checks were successful
CI / ci (push) Successful in 14m35s
CI / build (push) Successful in 45s
2026-05-02 11:29:26 +02:00
c808696a3f 27 - Fix CI build: drop setup-node, use Node pre-installed in act image
All checks were successful
CI / ci (push) Successful in 13m49s
CI / build (push) Successful in 1m25s
2026-05-02 11:00:47 +02:00
965dc88455 27 - Fix CI: drop service healthcheck, use nc wait loop for MySQL
Some checks failed
CI / ci (push) Has been cancelled
CI / build (push) Has been cancelled
2026-05-02 10:33:06 +02:00
856646ccde 27 - Fix CI MySQL health check: use mysql client instead of mysqladmin
Some checks failed
CI / ci (push) Failing after 2m13s
CI / build (push) Has been cancelled
2026-05-02 10:28:32 +02:00
36678b4b57 27 - Add Forgejo CI: PHP tests, lint, coverage, asset build
Some checks failed
CI / ci (push) Failing after 2m49s
CI / build (push) Successful in 2m38s
2026-05-02 10:19:26 +02:00
476073bc00 36 - Remove Redis: unused in this app, simplify dev environment 2026-05-02 10:02:39 +02:00
16b579eceb 37 - Fix PR review findings: needsOnboarding gate, onSkip guard, sed patterns, compose cleanup 2026-05-02 09:57:05 +02:00
c388452942 37 - Fix dev environment: paths, PHP version, testing DB, env isolation 2026-05-02 09:52:42 +02:00
dd5f1c514e 37 - Add tracker type choice as first onboarding step 2026-05-01 23:31:01 +02:00
c6a1681876 37 - Flatten docker/dev structure and relax onboarding completion check 2026-05-01 23:27:38 +02:00
818e8b2276 23 - Price tracking opt-in: migration, flag, conditional UI, onboarding checkbox 2026-05-01 22:02:13 +02:00
0861cff8b4 30 - Fix dev Dockerfile: remove Node.js apt conflict, drop composer.lock dependency 2026-05-01 21:20:56 +02:00
ed17529906 22 - Generalize UI copy, remove VWCE hardcoding 2026-05-01 21:03:05 +02:00
ba6bb62e73 26 - Add nix-shell and rewrite README 2026-05-01 20:59:56 +02:00
96 changed files with 12636 additions and 5382 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
node_modules
vendor
.git
.forgejo
docker/dev
.env
.env.*
.env.testing
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
tests

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
@ -40,13 +40,6 @@ QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1

11
.env.testing Normal file
View file

@ -0,0 +1,11 @@
APP_ENV=testing
APP_KEY=base64:+7T2RuonhTIij1yLp3rTOv2uQlYJh0TQulu20MlCA+s=
DB_CONNECTION=mysql
DB_HOST=db
DB_DATABASE=testing
DB_USERNAME=incr_user
DB_PASSWORD=incr_password
SESSION_DRIVER=array
CACHE_STORE=array

View file

@ -0,0 +1,47 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up QEMU
uses: https://data.forgejo.org/docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
- name: Login to Forgejo Registry
uses: https://data.forgejo.org/docker/login-action@v3
with:
registry: forge.lvl0.xyz
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Determine tags
id: meta
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
echo "tags=forge.lvl0.xyz/lvl0/incr:${TAG},forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
else
echo "tags=forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
fi
- name: Build and push
uses: https://data.forgejo.org/docker/build-push-action@v5
with:
context: .
file: docker/production/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}

142
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,142 @@
name: CI
on:
push:
branches: ['release/*']
pull_request:
branches: [main]
jobs:
ci:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
services:
db:
image: mysql:8.0
env:
MYSQL_DATABASE: testing
MYSQL_USER: incr_user
MYSQL_PASSWORD: incr_password
MYSQL_ROOT_PASSWORD: root_password
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up PHP
uses: https://github.com/shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_mysql, mbstring, xml, dom, bcmath, gd, exif, pcntl
coverage: pcov
- name: Cache Composer dependencies
uses: https://data.forgejo.org/actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- name: Install PHP dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Prepare environment
run: cp .env.testing .env
- name: Wait for MySQL
run: |
for i in $(seq 1 30); do
nc -z db 3306 && echo "MySQL is up" && break
echo "Waiting for MySQL... ($i/30)"
sleep 2
done
- name: Run migrations
run: php artisan migrate --force
- name: Lint
run: vendor/bin/pint --test
- name: Tests
run: php artisan test --coverage-clover coverage.xml --coverage-text
- name: Parse coverage
if: github.event_name == 'pull_request'
id: coverage
run: |
COVERAGE=$(php -r '
$xml = simplexml_load_file("coverage.xml");
if ($xml === false || !isset($xml->project->metrics)) {
echo "0";
exit;
}
$metrics = $xml->project->metrics;
$statements = (int) $metrics["statements"];
$covered = (int) $metrics["coveredstatements"];
echo $statements > 0 ? round(($covered / $statements) * 100, 2) : 0;
')
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COVERAGE: ${{ steps.coverage.outputs.percentage }}
REPO: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
COMMIT_SHA: ${{ github.sha }}
run: |
API_URL="${SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments"
MARKER="<!-- incr-ci-coverage-report -->"
BODY="${MARKER}
## Code Coverage Report
| Metric | Value |
|--------|-------|
| **Line Coverage** | ${COVERAGE}% |
_Updated by CI — commit ${COMMIT_SHA}_"
EXISTING=$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" \
"${API_URL}?limit=50" | \
php -r '
$comments = json_decode(file_get_contents("php://stdin"), true);
if (!is_array($comments)) exit;
foreach ($comments as $c) {
if (str_contains($c["body"], "<!-- incr-ci-coverage-report -->")) {
echo $c["id"];
exit;
}
}
' || true)
if [ -n "$EXISTING" ]; then
curl -sf -X PATCH \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
"${SERVER_URL}/api/v1/repos/${REPO}/issues/comments/${EXISTING}" > /dev/null
else
curl -sf -X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
"${API_URL}" > /dev/null
fi
build:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Install JS dependencies
run: npm ci
- name: Build assets
run: npm run build

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

195
README.md
View file

@ -1,157 +1,108 @@
<div align="center">
# 📈 incr
# incr
**A minimalist investment tracker for VWCE shares with milestone-driven progress**
**A minimalist counter with milestone-driven progress**
*Track your portfolio growth with visual progress indicators and milestone reinforcement*
*Track anything you accumulate — with visual progress and milestone reinforcement*
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com/)
[![Laravel](https://img.shields.io/badge/Laravel-12-FF2D20?logo=laravel&logoColor=white)](https://laravel.com/)
[![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)](https://reactjs.org/)
---
**[Introduction](#introduction) • [Features](#features) • [Tech Stack](#tech-stack) • [Getting Started](#getting-started) • [Development](#development) • [Contributing](#contributing) • [License](#license)**
---
</div>
## Introduction
## About
Incr is a minimalist, one-page investment tracking application designed specifically for VWCE (Vanguard FTSE All-World UCITS ETF) shareholders. It combines the satisfaction of visual progress tracking with practical portfolio management, featuring a distinctive LED-style digital display and milestone-based goal setting.
incr is a minimalist, self-hosted tracking app. Pick something you want to accumulate — shares, books, workouts, anything — log your progress, and watch milestones fall.
The application emphasizes simplicity and focus, providing just what you need to track your investment journey without overwhelming complexity.
It features a distinctive LED-style digital display, configurable milestones, and optional price tracking.
## Features
- **LED-style display**: Large red digital counter showing current share count
- **Progress tracking**: Visual progress bar toward configurable milestones
- **Purchase management**: Add and track share purchases with historical data
- **Financial insights**: Portfolio value and withdrawal estimates
- **Milestone cycling**: Track progress toward multiple investment goals (1500→3000→4500→6000)
- **LED-style display** — large red digital counter
- **Milestone tracking** — set targets, cycle through them as you progress
- **Purchase logging** — add entries with date and optional price
- **Price tracking** — optional; log a current price to see portfolio value and P/L
- **Self-hosted** — your data stays on your server
## Tech Stack
- **Backend**: Laravel 12 (PHP 8.2+) with MySQL database
- **Backend**: Laravel 12 (PHP 8.3+) with MySQL
- **Frontend**: React 19 + TypeScript with Inertia.js
- **Styling**: Tailwind CSS 4 with shadcn/ui components
- **Deployment**: Docker with multi-stage builds
- **Deployment**: Docker / Podman with multi-stage builds
## Getting Started
## Self-hosting
### Quick Start (Production)
```yaml
# docker-compose.yml
services:
app:
image: forge.lvl0.xyz/lvl0/incr:latest
container_name: incr-app
restart: unless-stopped
environment:
- APP_ENV=production
- APP_DEBUG=false
- APP_KEY=base64:YOUR_APP_KEY_HERE
- DB_CONNECTION=mysql
- DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=incr
- DB_USERNAME=incr_user
- DB_PASSWORD=change_me
ports:
- "5001:80"
depends_on:
db:
condition: service_healthy
networks:
- incr-network
#### Docker
db:
image: mysql:8.0
container_name: incr-db
restart: unless-stopped
environment:
- MYSQL_DATABASE=incr
- MYSQL_USER=incr_user
- MYSQL_PASSWORD=change_me
- MYSQL_ROOT_PASSWORD=change_me_root
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "incr_user", "-pchange_me"]
timeout: 10s
retries: 10
interval: 10s
start_period: 10s
networks:
- incr-network
Clone the repository and run with Docker Compose:
networks:
incr-network:
driver: bridge
```bash
git clone https://github.com/your-username/incr.git
cd incr
volumes:
db_data:
```
Run the application using the provided docker-compose configuration:
Generate an app key with: `php artisan key:generate --show`
The app will be available at `http://localhost:5001`.
## Development
Requires [Nix](https://nixos.org/download/). Enter the dev shell:
```bash
# Using Docker Compose
docker-compose -f docker/production/docker-compose.yml up --build
# Or using Podman Compose
podman-compose -f docker/production/docker-compose.yml up --build
nix-shell
dev-up
```
The application will be available at `http://localhost:5001`.
### Development
#### Local Development Setup
**Option 1: Laravel Sail (Docker)**
For local development with Laravel Sail:
```bash
# Install Laravel Sail
composer install
sail artisan sail:install
# Start development environment
sail up -d
# Install frontend dependencies and build assets
npm install
npm run dev
# Run migrations
sail artisan migrate
```
**Option 2: Podman Development**
For Fedora Atomic or other Podman-based systems:
```bash
# Quick start with helper script
bash docker/dev/podman/start-dev.sh
# Or manually:
# Install podman-compose if not available
pip3 install --user podman-compose
# Start development environment
podman-compose -f docker/dev/podman/docker-compose.yml up -d
# Run migrations
podman exec incr-dev-app php artisan migrate
```
**Option 3: Sail with Podman (Compatibility Layer)**
To use Laravel Sail commands with Podman:
```bash
# Source the alias script
source docker/dev/podman/podman-sail-alias.sh
# Now you can use sail commands as normal
sail up -d
sail artisan migrate
sail npm run dev
```
The development server will be available at `http://localhost` with hot reload enabled.
## Project Structure
- `app/` - Laravel backend (controllers, models, services)
- `resources/js/` - React frontend components and pages
- `docker/production/` - Production Docker configuration
- `docker/dev/podman/` - Development Podman configuration
- `database/migrations/` - Database schema definitions
## Contributing
We welcome contributions to incr! Whether you're reporting bugs, suggesting features, or submitting pull requests, your input helps make this project better.
### How to Contribute
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Bug Reports
If you find a bug, please create an issue with:
- A clear description of the problem
- Steps to reproduce the issue
- Expected vs actual behavior
- Your environment details
Available commands inside the shell: `dev-up`, `dev-down`, `dev-rebuild`, `dev-logs`, `dev-shell`, `dev-artisan`, `dev-composer`.
## License
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
GNU General Public License v3.0 — see [LICENSE](LICENSE).

View file

@ -1,60 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Asset;
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 = \App\Models\User::first();
$asset = $user ? $user->asset : null;
return response()->json([
'asset' => $asset,
]);
}
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 = \App\Models\User::first();
if (!$user) {
// Create a default user if none exists
$user = \App\Models\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
@ -79,19 +37,18 @@ 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(),
]);
}
public function search(Request $request): JsonResponse
{
$query = $request->get('q');
if (!$query) {
if (! $query) {
return response()->json([]);
}
@ -103,4 +60,4 @@ public function search(Request $request): JsonResponse
return response()->json($assets);
}
}
}

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 \Illuminate\Validation\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,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\Password;
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 \Illuminate\Validation\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

@ -10,6 +10,7 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@ -20,26 +21,34 @@ class RegisteredUserController extends Controller
*/
public function create(): Response
{
if (User::exists()) {
abort(403, 'Registration is disabled.');
}
return Inertia::render('auth/register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
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,30 +0,0 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
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 \Illuminate\Contracts\Auth\MustVerifyEmail $user */
$user = $request->user();
event(new Verified($user));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View file

@ -1,34 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Milestones;
use App\Http\Controllers\Controller;
use App\Models\Milestone;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
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,24 +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 = \App\Models\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),
]);
}
@ -29,41 +34,33 @@ public function update(Request $request)
'price' => 'required|numeric|min:0.0001',
]);
// Get the first/default user (since no auth)
$user = \App\Models\User::first();
if (!$user || !$user->asset_id) {
if (! $this->tracker?->asset_id) {
return back()->withErrors(['asset' => 'Please set an asset first.']);
}
$assetPrice = AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
AssetPrice::updatePrice($this->tracker->asset_id, $validated['date'], $validated['price']);
if (! $this->tracker->price_tracking_enabled) {
$this->tracker->update(['price_tracking_enabled' => true]);
}
return back()->with('success', 'Asset price updated successfully!');
}
public function history(Request $request): JsonResponse
{
// Get the first/default user (since no auth)
$user = \App\Models\User::first();
$assetId = $user ? $user->asset_id : null;
$limit = $request->get('limit', 30);
$history = AssetPrice::history($assetId, $limit);
$limit = min(max(1, $request->integer('limit', 30)), 365);
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 = \App\Models\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,101 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Asset;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TrackerController extends Controller
{
public function show(): JsonResponse
{
$tracker = User::default()->tracker;
if (! $tracker) {
return response()->json(['exists' => false]);
}
return response()->json(['exists' => true, 'tracker' => $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): RedirectResponse|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 back()->withErrors(['tracker' => 'No tracker found.']);
}
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 back();
}
}

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,72 +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;
use Illuminate\Support\Facades\Redirect;
class PurchaseController extends Controller
{
public function index(): JsonResponse
{
$purchases = Purchase::orderBy('date', 'desc')->get();
return response()->json($purchases);
}
public function store(Request $request)
{
$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

@ -12,7 +12,7 @@ class HandleAppearance
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{

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

@ -3,6 +3,7 @@
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
@ -22,7 +23,7 @@ public function authorize(): bool
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@ -35,7 +36,7 @@ public function rules(): array
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function authenticate(): void
{
@ -55,7 +56,7 @@ public function authenticate(): void
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{

View file

@ -11,6 +11,7 @@
* @method static where(string $string, string $value)
* @method static find(int $id)
* @method static orderBy(string $string)
*
* @property int $id
* @property string $symbol
* @property string|null $full_name
@ -34,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);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models\Pricing;
use App\Models\Asset;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -13,6 +14,7 @@
* @method static where(string $string, string $string1, string $date)
* @method static updateOrCreate(string[] $array, float[] $array1)
* @method static orderBy(string $string, string $string1)
*
* @property Carbon $date
* @property float $price
*/
@ -33,13 +35,13 @@ class AssetPrice extends Model
public function asset(): BelongsTo
{
return $this->belongsTo(\App\Models\Asset::class);
return $this->belongsTo(Asset::class);
}
public static function current(int $assetId = null): ?float
public static function current(?int $assetId = null): ?float
{
$query = static::latest('date');
if ($assetId) {
$query->where('asset_id', $assetId);
}
@ -49,11 +51,11 @@ public static function current(int $assetId = null): ?float
return $latestPrice ? $latestPrice->price : null;
}
public static function forDate(string $date, int $assetId = null): ?float
public static function forDate(string $date, ?int $assetId = null): ?float
{
$query = static::where('date', '<=', $date)
->orderBy('date', 'desc');
if ($assetId) {
$query->where('asset_id', $assetId);
}
@ -71,10 +73,10 @@ public static function updatePrice(int $assetId, string $date, float $price): se
);
}
public static function history(int $assetId = null, int $limit = 30): Collection
public static function history(?int $assetId = null, int $limit = 30): Collection
{
$query = static::orderBy('date', 'desc')->limit($limit);
if ($assetId) {
$query->where('asset_id', $assetId);
}

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,37 +2,23 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
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 $asset_id
*/
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'asset_id',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
@ -46,26 +32,33 @@ protected function casts(): array
];
}
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
{
// Check if user has asset, purchases, and milestones
return $this->asset_id !== null
&& $this->hasPurchases()
&& $this->hasMilestones();
return $this->hasEntries() && $this->hasMilestones();
}
public function hasPurchases(): bool
public function hasEntries(): bool
{
return \App\Models\Transactions\Purchase::totalShares() > 0;
return (bool) $this->tracker?->entries()->exists();
}
public function hasMilestones(): bool
{
return \App\Models\Milestone::count() > 0;
return (bool) $this->tracker?->milestones()->exists();
}
}

View file

@ -1,5 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
App\Providers\AppServiceProvider::class,
AppServiceProvider::class,
];

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

@ -1,5 +1,7 @@
<?php
use App\Models\User;
return [
/*
@ -62,7 +64,7 @@
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [

View file

@ -2,12 +2,13 @@
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
* @extends Factory<User>
*/
class UserFactory extends Factory
{

View file

@ -18,6 +18,7 @@ public function up(): void
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->foreignId('asset_id')->nullable()->constrained()->onDelete('set null');
$table->boolean('price_tracking_enabled')->default(false);
$table->rememberToken();
$table->timestamps();

View file

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

View file

@ -0,0 +1,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,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('entries', function (Blueprint $table): void {
$table->id();
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
$table->date('date');
$table->decimal('quantity', 12, 6);
$table->decimal('unit_price', 12, 4)->nullable();
$table->decimal('total_cost', 12, 2)->nullable();
$table->timestamps();
$table->index(['tracker_id', 'date']);
});
}
public function down(): void
{
Schema::dropIfExists('entries');
}
};

View file

@ -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

@ -1,4 +1,4 @@
FROM docker.io/library/php:8.2-fpm
FROM docker.io/library/php:8.3-fpm
# Install system dependencies
RUN apt-get update && apt-get install -y \
@ -9,39 +9,21 @@ RUN apt-get update && apt-get install -y \
libxml2-dev \
zip \
unzip \
nodejs \
npm \
default-mysql-client \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
# Install Node.js 20.x via nodesource
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Install Node.js 20.x (for better compatibility)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs
# Copy composer files and install PHP dependencies
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts
# Copy package.json and install Node dependencies
COPY package*.json ./
RUN npm ci
# Copy application code
COPY . .
# Set permissions
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/storage \
&& chmod -R 755 /var/www/html/bootstrap/cache
# Copy and set up container start script
COPY docker/dev/podman/container-start.sh /usr/local/bin/container-start.sh
COPY docker/dev/container-start.sh /usr/local/bin/container-start.sh
RUN chmod +x /usr/local/bin/container-start.sh
EXPOSE 8000 5173

View file

@ -1,4 +1,5 @@
#!/bin/bash
set -e
# Create .env file if it doesn't exist
if [ ! -f /var/www/html/.env ]; then
@ -6,15 +7,19 @@ if [ ! -f /var/www/html/.env ]; then
fi
# Fix database name to match compose file
sed -i 's/DB_DATABASE=incr$/DB_DATABASE=incr_dev/' /var/www/html/.env
sed -i 's|^DB_DATABASE=.*|DB_DATABASE=incr_dev|' /var/www/html/.env
# Generate app key if not set or empty
if ! grep -q "APP_KEY=base64:" /var/www/html/.env; then
# Generate a new key and set it directly
NEW_KEY=$(php -r "echo 'base64:' . base64_encode(random_bytes(32));")
sed -i "s/APP_KEY=/APP_KEY=$NEW_KEY/" /var/www/html/.env
sed -i "s|^APP_KEY=.*|APP_KEY=$NEW_KEY|" /var/www/html/.env
fi
# Install dependencies if needed
[ ! -f vendor/autoload.php ] && composer install --no-interaction
[ ! -d node_modules/.bin ] && npm install
# Run migrations
php artisan migrate --force

View file

@ -1,28 +1,16 @@
version: '3.8'
services:
app:
build:
context: ../../..
dockerfile: docker/dev/podman/Dockerfile
context: ../..
dockerfile: docker/dev/Dockerfile
container_name: incr-dev-app
restart: unless-stopped
working_dir: /var/www/html
environment:
- APP_ENV=local
- APP_DEBUG=true
- APP_KEY=base64:YOUR_APP_KEY_HERE
- DB_CONNECTION=mysql
- DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=incr_dev
- DB_USERNAME=incr_user
- DB_PASSWORD=incr_password
- VITE_PORT=5173
volumes:
- ../../../:/var/www/html:Z
- /var/www/html/node_modules
- /var/www/html/vendor
- ../../:/var/www/html:Z
- app_node_modules:/var/www/html/node_modules
ports:
- "8000:8000"
- "5173:5173"
@ -43,6 +31,7 @@ services:
- MYSQL_ROOT_PASSWORD=root_password
volumes:
- db_data:/var/lib/mysql
- ./mysql-init:/docker-entrypoint-initdb.d:ro
ports:
- "3307:3306"
healthcheck:
@ -54,19 +43,12 @@ services:
networks:
- incr-dev-network
redis:
image: docker.io/library/redis:7-alpine
container_name: incr-dev-redis
restart: unless-stopped
ports:
- "6379:6379"
networks:
- incr-dev-network
networks:
incr-dev-network:
driver: bridge
volumes:
db_data:
driver: local
driver: local
app_node_modules:
driver: local

View file

@ -0,0 +1,2 @@
CREATE DATABASE IF NOT EXISTS `testing` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
GRANT ALL PRIVILEGES ON `testing`.* TO 'incr_user'@'%';

View file

@ -2,7 +2,7 @@
# Podman aliases for Laravel Sail compatibility
# Source this file to use Sail commands with Podman
# Usage: source docker/dev/podman/podman-sail-alias.sh
# Usage: source docker/dev/podman-sail-alias.sh
# Create docker alias pointing to podman
alias docker='podman'
@ -12,10 +12,10 @@ alias docker-compose='podman-compose'
# Sail wrapper function that uses podman-compose
sail() {
if [[ -f docker/dev/podman/docker-compose.yml ]]; then
podman-compose -f docker/dev/podman/docker-compose.yml "$@"
if [[ -f docker/dev/docker-compose.yml ]]; then
podman-compose -f docker/dev/docker-compose.yml "$@"
else
echo "❌ Podman compose file not found at docker/dev/podman/docker-compose.yml"
echo "❌ Podman compose file not found at docker/dev/docker-compose.yml"
return 1
fi
}

View file

@ -22,7 +22,7 @@ fi
# Start services
echo "🔧 Starting services..."
podman-compose -f docker/dev/podman/docker-compose.yml up -d
podman-compose -f docker/dev/docker-compose.yml up -d
# Wait for database to be ready
echo "⏳ Waiting for database to be ready..."
@ -48,5 +48,5 @@ echo "🌐 Application: http://localhost:8000"
echo "🔥 Vite dev server: http://localhost:5173"
echo "💾 Database: localhost:3307"
echo ""
echo "To stop: podman-compose -f docker/dev/podman/docker-compose.yml down"
echo "To view logs: podman-compose -f docker/dev/podman/docker-compose.yml logs -f"
echo "To stop: podman-compose -f docker/dev/docker-compose.yml down"
echo "To view logs: podman-compose -f docker/dev/docker-compose.yml logs -f"

View file

@ -7,7 +7,7 @@ WORKDIR /app
COPY package*.json ./
# Install Node dependencies
RUN npm ci --only=production
RUN npm ci
# Copy frontend source
COPY resources/ resources/
@ -21,7 +21,7 @@ COPY eslint.config.js ./
RUN npm run build
# PHP runtime stage
FROM php:8.2-fpm-alpine
FROM php:8.3-fpm-alpine
# Install system dependencies
RUN apk add --no-cache \
@ -61,9 +61,9 @@ RUN composer install --no-dev --optimize-autoloader --no-interaction
COPY --from=frontend-builder /app/public/build/ ./public/build/
# Copy nginx and supervisor configurations
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/start-app.sh /usr/local/bin/start-app
COPY docker/production/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/production/supervisord.conf /etc/supervisord.conf
COPY docker/production/start-app.sh /usr/local/bin/start-app
# Set proper permissions
RUN chown -R www-data:www-data storage bootstrap/cache public/build \

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,14 +10,16 @@ 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
echo "Database is ready!"
# Generate app key if not set
php artisan key:generate --force
# Generate app key only if not already set
if ! grep -q "APP_KEY=base64:" /var/www/html/.env 2>/dev/null; then
php artisan key:generate --force
fi
# Laravel optimizations
php artisan config:cache

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

@ -18,15 +18,15 @@
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_DATABASE" value="testing"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="APP_ENV" value="testing" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
<env name="CACHE_STORE" value="array" force="true"/>
<env name="DB_DATABASE" value="testing" force="true"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="PULSE_ENABLED" value="false" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
</php>
</phpunit>

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,65 +1,50 @@
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';
type FormType = 'purchase' | 'milestone';
interface InlineFormProps {
type: 'purchase' | 'milestone' | 'price' | null;
type: FormType | null;
unit?: string;
onClose: () => void;
onPurchaseSuccess?: () => void;
onMilestoneSuccess?: () => void;
onPriceSuccess?: () => void;
onSuccess?: (type: FormType) => void;
className?: string;
}
export default function InlineForm({
type,
unit = 'units',
onClose,
onPurchaseSuccess,
onMilestoneSuccess,
onPriceSuccess,
className
onSuccess,
className,
}: InlineFormProps) {
if (!type) return null;
const title = type === 'purchase' ? 'ADD PURCHASE' : type === 'milestone' ? 'ADD MILESTONE' : 'UPDATE PRICE';
const handleSuccess = () => {
if (onSuccess) onSuccess(type);
onClose();
};
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
onSuccess={() => {
if (onPurchaseSuccess) onPurchaseSuccess();
onClose();
}}
onCancel={onClose}
/>
) : type === 'milestone' ? (
<AddMilestoneForm
onSuccess={() => {
if (onMilestoneSuccess) onMilestoneSuccess();
onClose();
}}
<AddEntryForm
unit={unit}
onSuccess={handleSuccess}
onCancel={onClose}
/>
) : (
<UpdatePriceForm
onSuccess={() => {
if (onPriceSuccess) onPriceSuccess();
onClose();
}}
<AddMilestoneForm
onSuccess={handleSuccess}
onCancel={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

@ -1,34 +1,27 @@
import { cn } from '@/lib/utils';
interface Milestone {
target: number;
description: string;
created_at: string;
}
import type { Milestone } from '@/types/domain';
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

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

View file

@ -0,0 +1,113 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler, useState } from 'react';
import ComponentTitle from '@/components/ui/ComponentTitle';
interface CreateTrackerStepProps {
onSuccess: () => void;
}
export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps) {
const [label, setLabel] = useState('');
const [unit, setUnit] = useState('');
const [processing, setProcessing] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const submit: FormEventHandler = async (e) => {
e.preventDefault();
setProcessing(true);
setErrors({});
try {
const response = await fetch(route('tracker.store'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
'Accept': 'application/json',
},
body: JSON.stringify({
label,
unit,
price_tracking_enabled: 0,
}),
});
if (response.ok || response.status === 201 || response.status === 409) {
onSuccess();
} else {
const data = await response.json();
if (data.errors) {
setErrors(data.errors);
} else if (data.message) {
setErrors({ label: data.message });
}
}
} catch {
setErrors({ label: 'Something went wrong. Please try again.' });
} finally {
setProcessing(false);
}
};
return (
<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={label}
onChange={(e) => setLabel(e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
/>
<p className="text-xs text-red-400/60 mt-1 font-mono">
[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={unit}
onChange={(e) => setUnit(e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
/>
<p className="text-xs text-red-400/60 mt-1 font-mono">
[REQUIRED] e.g. "shares", "books", "km"
</p>
<InputError message={errors.unit} />
</div>
<Button
type="submit"
disabled={processing || !label || !unit}
className="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
>
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
[INITIALIZE]
</Button>
</form>
</div>
</div>
);
}

View file

@ -1,8 +1,7 @@
import { useState, useEffect } from 'react';
import AssetSetupForm from '@/components/Assets/AssetSetupForm';
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
import { useState, useEffect, useCallback } from 'react';
import AddEntryForm from '@/components/Transactions/AddEntryForm';
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
import CreateTrackerStep from '@/components/Onboarding/CreateTrackerStep';
interface OnboardingStep {
id: string;
@ -12,121 +11,81 @@ interface OnboardingStep {
required: boolean;
}
const STEPS: OnboardingStep[] = [
{ id: 'entries', title: 'STARTING AMOUNT', description: 'Enter your starting amount', completed: false, required: true },
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
];
interface OnboardingFlowProps {
onComplete?: () => void;
}
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const [trackerCreated, setTrackerCreated] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [steps, setSteps] = useState<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: 'milestones',
title: 'SET MILESTONES',
description: 'Define your investment goals',
completed: false,
required: true,
},
{
id: 'price',
title: 'CURRENT PRICE',
description: 'Set current asset price',
completed: false,
required: true,
},
]);
const [steps, setSteps] = useState<OnboardingStep[]>([]);
// Check onboarding status on mount
// On mount: check if a tracker already exists and skip step 1 if so
useEffect(() => {
checkOnboardingStatus();
fetch('/tracker')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.tracker) {
setTrackerCreated(true);
}
})
.catch(() => {});
}, []);
const checkOnboardingStatus = async () => {
const checkOnboardingStatus = useCallback(async (currentSteps: OnboardingStep[]) => {
try {
// Check asset
const assetResponse = await fetch('/assets/current');
const assetData = await assetResponse.json();
const hasAsset = !!assetData.asset;
const [entriesData, milestonesData] = await Promise.all([
fetch('/entries/summary').then(r => r.json()),
fetch('/milestones').then(r => r.json()),
]);
// Check purchases
const purchaseResponse = await fetch('/purchases/summary');
const purchaseData = await purchaseResponse.json();
const hasPurchases = purchaseData.total_shares > 0;
// Check milestones
const milestonesResponse = await fetch('/milestones');
const milestonesData = await milestonesResponse.json();
const hasEntries = entriesData.total_quantity > 0;
const hasMilestones = milestonesData.length > 0;
// Check current price
const priceResponse = await fetch('/pricing/current');
const priceData = await priceResponse.json();
const hasPrice = !!priceData.current_price;
setSteps(prev => prev.map(step => ({
const freshSteps = currentSteps.map(step => ({
...step,
completed:
(step.id === 'asset' && hasAsset) ||
(step.id === 'purchases' && hasPurchases) ||
(step.id === 'milestones' && hasMilestones) ||
(step.id === 'price' && hasPrice)
})));
completed:
(step.id === 'entries' && hasEntries) ||
(step.id === 'milestones' && hasMilestones),
}));
// Find first incomplete required step
const firstIncompleteStep = steps.findIndex(step =>
step.required && !step.completed
);
if (firstIncompleteStep !== -1) {
setCurrentStep(firstIncompleteStep);
} else {
// All required steps complete, check if we should call onComplete
const allRequiredComplete = steps.filter(s => s.required).every(s => s.completed);
if (allRequiredComplete && onComplete) {
onComplete();
}
setSteps(freshSteps);
const firstIncompleteRequired = freshSteps.findIndex(s => s.required && !s.completed);
if (firstIncompleteRequired !== -1) {
setCurrentStep(firstIncompleteRequired);
} else if (onComplete) {
onComplete();
}
} catch (error) {
console.error('Failed to check onboarding status:', error);
}
}, [onComplete]);
useEffect(() => {
if (!trackerCreated) return;
setSteps(STEPS);
setCurrentStep(0);
checkOnboardingStatus(STEPS);
}, [trackerCreated, checkOnboardingStatus]);
const handleTrackerCreated = () => {
setTrackerCreated(true);
};
const handleStepComplete = async () => {
// Mark current step as completed
setSteps(prev => prev.map((step, index) =>
const updatedSteps = steps.map((step, index) =>
index === currentStep ? { ...step, completed: true } : step
));
// Refresh onboarding status
await checkOnboardingStatus();
// Move to next incomplete step or complete onboarding
const nextIncompleteStep = steps.findIndex((step, index) =>
index > currentStep && step.required && !step.completed
);
if (nextIncompleteStep !== -1) {
setCurrentStep(nextIncompleteStep);
} else {
// All required steps complete
const allRequiredComplete = steps.filter(s => s.required).every(s => s.completed);
if (allRequiredComplete && onComplete) {
onComplete();
}
}
setSteps(updatedSteps);
await checkOnboardingStatus(updatedSteps);
};
const handleStepSelect = (stepIndex: number) => {
@ -135,32 +94,13 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const renderStepContent = () => {
const step = steps[currentStep];
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} />;
case 'milestones':
return (
<AddMilestoneForm
onSuccess={handleStepComplete}
/>
);
case 'price':
return (
<UpdatePriceForm
onSuccess={handleStepComplete}
/>
);
return <AddMilestoneForm onSuccess={handleStepComplete} />;
default:
return null;
}
@ -169,66 +109,69 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
return (
<div className="min-h-screen bg-black flex items-center justify-center p-4">
<div className="w-full max-w-4xl">
{/* Terminal-style border with red glow */}
<div className="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-red-400 font-mono text-2xl font-bold uppercase tracking-wider mb-2">
[SYSTEM] ONBOARDING SEQUENCE
</h1>
<p className="text-red-400/60 font-mono text-sm">
Initialize your asset tracking system
{!trackerCreated ? 'Set up your tracker' : 'Configure your tracker'}
</p>
</div>
{/* Progress indicator */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
{steps.map((step, index) => (
<button
key={step.id}
onClick={() => handleStepSelect(index)}
className={`flex-1 px-4 py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 transition-all ${
index === currentStep
? 'bg-red-500 text-black border-red-500'
: step.completed
? 'bg-red-950/50 text-red-300 border-red-400'
: 'bg-black text-red-400/60 hover:text-red-400 hover:border-red-400'
} ${index > 0 ? 'ml-2' : ''}`}
>
{step.completed ? '[✓]' : step.required ? '[REQ]' : '[OPT]'} {step.title}
</button>
))}
{!trackerCreated ? (
<div className="border border-red-500/30 bg-black/50 p-6">
<CreateTrackerStep onSuccess={handleTrackerCreated} />
</div>
<div className="text-center">
<p className="text-red-400 font-mono text-sm">
{steps[currentStep].description}
</p>
<p className="text-red-400/60 font-mono text-xs mt-1">
STEP {currentStep + 1}/{steps.length}
</p>
</div>
</div>
) : (
<>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
{steps.map((step, index) => (
<button
key={step.id}
onClick={() => handleStepSelect(index)}
className={`flex-1 px-4 py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 transition-all ${
index === currentStep
? 'bg-red-500 text-black border-red-500'
: step.completed
? 'bg-red-950/50 text-red-300 border-red-400'
: 'bg-black text-red-400/60 hover:text-red-400 hover:border-red-400'
} ${index > 0 ? 'ml-2' : ''}`}
>
{step.completed ? '[✓]' : '[REQ]'} {step.title}
</button>
))}
</div>
{/* Step content */}
<div className="border border-red-500/30 bg-black/50 p-6">
{renderStepContent()}
</div>
<div className="text-center">
<p className="text-red-400 font-mono text-sm">
{steps[currentStep]?.description}
</p>
<p className="text-red-400/60 font-mono text-xs mt-1">
STEP {currentStep + 1}/{steps.length}
</p>
</div>
</div>
{/* Status footer */}
<div className="mt-6 pt-4 border-t border-red-500/30">
<div className="flex justify-between items-center">
<p className="text-red-400/60 font-mono text-xs">
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
</p>
<p className="text-red-400/60 font-mono text-xs">
{steps.filter(s => s.required && !s.completed).length} REQUIRED REMAINING
</p>
</div>
</div>
<div className="border border-red-500/30 bg-black/50 p-6">
{renderStepContent()}
</div>
<div className="mt-6 pt-4 border-t border-red-500/30">
<div className="flex justify-between items-center">
<p className="text-red-400/60 font-mono text-xs">
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
</p>
<p className="text-red-400/60 font-mono text-xs">
{steps.filter(s => !s.completed).length} REQUIRED REMAINING
</p>
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}
}

View file

@ -3,6 +3,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/InputError';
import { useForm } from '@inertiajs/react';
import { todayISO } from '@/lib/utils';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import ComponentTitle from '@/components/ui/ComponentTitle';
@ -22,7 +23,7 @@ interface UpdatePriceFormProps {
export default function UpdatePriceForm({ currentPrice, className, onSuccess, onCancel }: UpdatePriceFormProps) {
const { data, setData, post, processing, errors } = useForm<PriceUpdateFormData>({
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
date: todayISO(), // Today's date in YYYY-MM-DD format
price: currentPrice?.toString() || '100.00',
});
@ -60,7 +61,7 @@ export default function UpdatePriceForm({ currentPrice, className, onSuccess, on
type="date"
value={data.date}
onChange={(e) => setData('date', e.target.value)}
max={new Date().toISOString().split('T')[0]}
max={todayISO()}
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} />

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,127 @@
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 { todayISO } from '@/lib/utils';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler, useEffect, useState } from 'react';
import ComponentTitle from '@/components/ui/ComponentTitle';
interface EntryFormData {
date: string;
quantity: string;
[key: string]: string;
}
interface AddEntryFormProps {
unit?: string;
onSuccess?: () => void;
onCancel?: () => void;
}
interface EntrySummary {
total_quantity: number;
}
export default function AddEntryForm({ unit = 'units', onSuccess, onCancel }: AddEntryFormProps) {
const { data, setData, post, processing, errors, reset } = useForm<EntryFormData>({
date: todayISO(),
quantity: '',
});
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();
}, []);
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('entries.store'), {
onSuccess: () => {
reset();
setData('date', todayISO());
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}
</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={todayISO()}
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>
<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,178 +0,0 @@
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 PurchaseFormData {
date: string;
shares: string;
price_per_share: string;
total_cost: string;
[key: string]: string;
}
interface AddPurchaseFormProps {
onSuccess?: () => void;
onCancel?: () => void;
}
interface PurchaseSummary {
total_shares: number;
total_investment: number;
average_cost_per_share: number;
}
export default function AddPurchaseForm({ onSuccess, onCancel }: AddPurchaseFormProps) {
const { data, setData, post, processing, errors, reset } = useForm<PurchaseFormData>({
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
shares: '',
price_per_share: '',
total_cost: '',
});
const [currentHoldings, setCurrentHoldings] = useState<PurchaseSummary | null>(null);
// Load existing holdings data on mount
useEffect(() => {
const fetchCurrentHoldings = async () => {
try {
const response = await fetch('/purchases/summary');
if (response.ok) {
const summary = await response.json();
setCurrentHoldings(summary);
}
} catch (error) {
console.error('Failed to fetch current holdings:', error);
}
};
fetchCurrentHoldings();
}, []);
// Auto-calculate total cost when shares or price changes
useEffect(() => {
if (data.shares && data.price_per_share) {
const shares = parseFloat(data.shares);
const pricePerShare = parseFloat(data.price_per_share);
if (!isNaN(shares) && !isNaN(pricePerShare)) {
const totalCost = (shares * pricePerShare).toFixed(2);
setData('total_cost', totalCost);
}
}
}, [data.shares, data.price_per_share, setData]);
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('purchases.store'), {
onSuccess: () => {
reset();
setData('date', new Date().toISOString().split('T')[0]);
if (onSuccess) {
onSuccess();
}
},
});
};
return (
<div className="w-full">
<div className="space-y-4">
<ComponentTitle>ADD PURCHASE</ComponentTitle>
{currentHoldings && currentHoldings.total_shares > 0 && (
<p className="text-sm text-red-400/60 font-mono">
[CURRENT] {currentHoldings.total_shares.toFixed(6)} shares {currentHoldings.total_investment.toFixed(2)} invested
</p>
)}
<form onSubmit={submit} className="space-y-4">
<div>
<Label htmlFor="date" className="text-red-400 font-mono text-xs uppercase tracking-wider">&gt; Purchase Date</Label>
<Input
id="date"
type="date"
value={data.date}
onChange={(e) => setData('date', e.target.value)}
max={new Date().toISOString().split('T')[0]}
className="bg-black border-red-500 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="shares" className="text-red-400 font-mono text-xs uppercase tracking-wider">&gt; Number of Shares</Label>
<Input
id="shares"
type="number"
step="0.000001"
min="0"
placeholder="1.234567"
value={data.shares}
onChange={(e) => setData('shares', e.target.value)}
className="bg-black border-red-500 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.shares} />
</div>
<div>
<Label htmlFor="price_per_share" className="text-red-400 font-mono text-xs uppercase tracking-wider">&gt; Price per Share ()</Label>
<Input
id="price_per_share"
type="number"
step="0.01"
min="0"
placeholder="123.45"
value={data.price_per_share}
onChange={(e) => setData('price_per_share', e.target.value)}
className="bg-black border-red-500 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.price_per_share} />
</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] shares × 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

@ -4,3 +4,5 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const todayISO = (): string => new Date().toISOString().split('T')[0];

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,77 +5,54 @@ 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';
import type { Milestone, Tracker } from '@/types/domain';
interface PurchaseSummary {
interface EntrySummary {
total_shares: number;
total_investment: number;
average_cost_per_share: number;
}
interface CurrentPrice {
current_price: number | null;
}
interface Milestone {
target: number;
description: string;
created_at: string;
}
export default function Dashboard() {
const [purchaseData, setPurchaseData] = useState<PurchaseSummary>({
total_shares: 0,
total_investment: 0,
average_cost_per_share: 0,
});
const [priceData, setPriceData] = useState<CurrentPrice>({
current_price: null,
});
const [totalShares, setTotalShares] = useState(0);
const [milestones, setMilestones] = useState<Milestone[]>([]);
const [selectedMilestoneIndex, setSelectedMilestoneIndex] = useState(0);
const [showProgressBar, setShowProgressBar] = useState(false);
const [showStatsBox, setShowStatsBox] = useState(false);
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | 'price' | null>(null);
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | null>(null);
const [loading, setLoading] = useState(true);
const [needsOnboarding, setNeedsOnboarding] = useState(false);
const [currentAsset, setCurrentAsset] = useState<any>(null);
const [tracker, setTracker] = useState<Tracker | null>(null);
// Fetch purchase summary, current price, milestones, and check onboarding
useEffect(() => {
const fetchData = async () => {
try {
const [purchaseResponse, priceResponse, milestonesResponse, assetResponse] = await Promise.all([
fetch('/purchases/summary'),
fetch('/pricing/current'),
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
fetch('/entries/summary'),
fetch('/milestones'),
fetch('/assets/current'),
fetch('/tracker'),
]);
if (purchaseResponse.ok) {
const purchases = await purchaseResponse.json();
setPurchaseData(purchases);
}
let totalQuantity = 0;
let milestonesCount = 0;
if (priceResponse.ok) {
const price = await priceResponse.json();
setPriceData(price);
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setTotalShares(entries.total_quantity);
totalQuantity = entries.total_quantity;
}
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
milestonesCount = milestonesData.length;
}
if (assetResponse.ok) {
const assetData = await assetResponse.json();
setCurrentAsset(assetData.asset);
if (trackerResponse.ok) {
const { tracker: trackerData } = await trackerResponse.json();
setTracker(trackerData ?? null);
}
// 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 {
@ -86,54 +63,24 @@ 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 = !hasAsset || !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
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();
setTotalShares(entries.total_quantity);
}
} catch (error) {
console.error('Failed to refresh purchase data:', error);
console.error('Failed to refresh entry data:', error);
}
};
// Refresh milestones after successful creation
const handleMilestoneSuccess = async () => {
try {
const milestonesResponse = await fetch('/milestones');
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
// Reset to first milestone when milestones change
setSelectedMilestoneIndex(0);
}
} catch (error) {
@ -141,47 +88,39 @@ export default function Dashboard() {
}
};
// Handle milestone selection
const handleMilestoneSelect = (index: number) => {
setSelectedMilestoneIndex(index);
};
// Refresh price data after successful update
const handlePriceSuccess = async () => {
try {
const priceResponse = await fetch('/pricing/current');
if (priceResponse.ok) {
const price = await priceResponse.json();
setPriceData(price);
}
} catch (error) {
console.error('Failed to refresh price data:', error);
const handleOnboardingComplete = useCallback(async () => {
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
fetch('/entries/summary'),
fetch('/milestones'),
fetch('/tracker'),
]);
let totalQuantity = 0;
let milestonesCount = 0;
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setTotalShares(entries.total_quantity);
totalQuantity = entries.total_quantity;
}
};
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
milestonesCount = milestonesData.length;
}
// Calculate portfolio stats
const currentValue = priceData.current_price
? purchaseData.total_shares * priceData.current_price
: undefined;
if (trackerResponse.ok) {
const { tracker: trackerData } = await trackerResponse.json();
setTracker(trackerData ?? null);
}
const profitLoss = currentValue
? currentValue - purchaseData.total_investment
: undefined;
const profitLossPercentage = profitLoss && purchaseData.total_investment > 0
? (profitLoss / purchaseData.total_investment) * 100
: undefined;
const statsData = {
totalShares: purchaseData.total_shares,
totalInvestment: purchaseData.total_investment,
averageCostPerShare: purchaseData.average_cost_per_share,
currentPrice: priceData.current_price || undefined,
currentValue,
profitLoss,
profitLossPercentage,
};
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
}, []);
if (loading) {
return (
@ -192,56 +131,19 @@ export default function Dashboard() {
);
}
// Toggle handlers with cascading behavior
const handleLedClick = () => {
const newShowProgressBar = !showProgressBar;
setShowProgressBar(newShowProgressBar);
if (!newShowProgressBar) {
// If hiding progress bar, also hide stats box
setShowStatsBox(false);
}
};
const handleProgressClick = () => {
setShowStatsBox(!showStatsBox);
setActiveForm(null)
setActiveForm(null);
};
// Handle onboarding completion
const handleOnboardingComplete = 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'),
fetch('/pricing/current'),
fetch('/milestones'),
fetch('/assets/current'),
]);
if (purchaseResponse.ok) {
const purchases = await purchaseResponse.json();
setPurchaseData(purchases);
}
if (priceResponse.ok) {
const price = await priceResponse.json();
setPriceData(price);
}
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
}
if (assetResponse.ok) {
const assetData = await assetResponse.json();
setCurrentAsset(assetData.asset);
}
};
// Show onboarding if needed
if (needsOnboarding) {
return (
<>
@ -253,50 +155,48 @@ export default function Dashboard() {
return (
<>
<Head title="VWCE Tracker" />
<Head title="incr" />
{/* Stacked Layout */}
<div className="min-h-screen bg-black">
<div className="w-full max-w-4xl mx-auto px-4">
{/* Box 1: LED Number Display - Fixed position from top */}
<div className="pt-32">
<LedDisplay
value={purchaseData.total_shares}
value={totalShares}
unit={tracker?.unit}
onClick={handleLedClick}
/>
</div>
{/* Box 2: Progress Bar (toggleable) */}
<div style={{ display: showProgressBar ? 'block' : 'none' }}>
<ProgressBar
currentShares={purchaseData.total_shares}
currentQuantity={totalShares}
milestones={milestones}
selectedMilestoneIndex={selectedMilestoneIndex}
onClick={handleProgressClick}
/>
</div>
{/* Box 3: Stats Box (toggleable) */}
<div style={{ display: showStatsBox ? 'block' : 'none' }}>
<StatsBox
stats={statsData}
stats={{ totalShares }}
unit={tracker?.unit}
milestones={milestones}
selectedMilestoneIndex={selectedMilestoneIndex}
onMilestoneSelect={handleMilestoneSelect}
onAddPurchase={() => setActiveForm('purchase')}
onAddMilestone={() => setActiveForm('milestone')}
onUpdatePrice={() => setActiveForm('price')}
/>
</div>
{/* Box 4: Forms (only when active form is set) */}
<div style={{ display: activeForm && showProgressBar && showStatsBox ? 'block' : 'none' }}>
<InlineForm
type={activeForm}
unit={tracker?.unit}
onClose={() => setActiveForm(null)}
onPurchaseSuccess={handlePurchaseSuccess}
onMilestoneSuccess={handleMilestoneSuccess}
onPriceSuccess={handlePriceSuccess}
onSuccess={(type) => {
if (type === 'purchase') handlePurchaseSuccess();
else if (type === 'milestone') handleMilestoneSuccess();
}}
/>
</div>
</div>

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

@ -0,0 +1,20 @@
export interface Milestone {
id?: number;
target: number;
description: string;
created_at: string;
}
export interface TrackerAsset {
id: number;
symbol: string;
full_name: string | null;
}
export interface Tracker {
id: number;
label: string;
unit: string;
price_tracking_enabled: boolean;
asset: TrackerAsset | null;
}

View file

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

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

@ -1,9 +1,10 @@
<?php
use App\Http\Controllers\AssetController;
use App\Http\Controllers\Transactions\PurchaseController;
use App\Http\Controllers\Pricing\PricingController;
use App\Http\Controllers\Milestones\MilestoneController;
use App\Http\Controllers\Pricing\PricingController;
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';

148
shell.nix Normal file
View file

@ -0,0 +1,148 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
# PHP and tools
php83
php83Packages.composer
# Node.js (needed for Tailwind CSS / Vite)
nodejs_22
# Container tools
podman
podman-compose
# Utilities
git
curl
gnumake
];
shellHook = ''
export USER_ID=$(id -u)
export GROUP_ID=$(id -g)
export PODMAN_USERNS=keep-id
# Compose file location
COMPOSE_FILE="$PWD/docker/dev/docker-compose.yml"
# ===================
# ALIASES
# ===================
alias pc='podman-compose -f $COMPOSE_FILE'
# ===================
# DEV COMMANDS
# ===================
dev-up() {
echo "Starting services..."
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d "$@"
echo ""
podman-compose -f $COMPOSE_FILE ps
echo ""
echo "App available at: http://localhost:8000"
}
dev-down() {
if [[ "$1" == "-v" ]]; then
echo "Stopping services and removing volumes..."
podman-compose -f $COMPOSE_FILE down -v
else
echo "Stopping services..."
podman-compose -f $COMPOSE_FILE down
fi
}
dev-restart() {
echo "Restarting services..."
podman-compose -f $COMPOSE_FILE restart "$@"
}
dev-rebuild() {
echo "Rebuilding services (down -v + up --build)..."
podman-compose -f $COMPOSE_FILE down -v
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d --build "$@"
echo ""
podman-compose -f $COMPOSE_FILE ps
echo ""
echo "App available at: http://localhost:8000"
}
dev-logs() {
podman-compose -f $COMPOSE_FILE logs -f app "$@"
}
dev-logs-db() {
podman-compose -f $COMPOSE_FILE logs -f db "$@"
}
dev-shell() {
podman-compose -f $COMPOSE_FILE exec app bash
}
dev-artisan() {
podman-compose -f $COMPOSE_FILE exec app php artisan "$@"
}
dev-composer() {
podman-compose -f $COMPOSE_FILE exec app composer "$@"
}
# ===================
# BUILD COMMANDS
# ===================
base-build() {
local image="forge.lvl0.xyz/lvl0/incr:latest"
if ! podman login --get-login forge.lvl0.xyz &>/dev/null; then
echo "Not logged in to forge.lvl0.xyz"
podman login forge.lvl0.xyz || return 1
fi
echo "Building image: $image"
if ! podman build -t "$image" -f Dockerfile .; then
echo "Build failed!"
return 1
fi
echo ""
echo "Pushing to registry..."
if ! podman push "$image"; then
echo "Push failed!"
return 1
fi
echo ""
echo "Done! Image pushed: $image"
}
# ===================
# WELCOME MESSAGE
# ===================
echo ""
echo "================================================="
echo " incr Dev Environment "
echo "================================================="
echo ""
echo " Podman: $(podman --version | cut -d' ' -f3)"
echo ""
echo "Commands:"
echo " dev-up [services] Start all or specific services"
echo " dev-down [-v] Stop services (-v removes volumes)"
echo " dev-rebuild Fresh start (down -v + up)"
echo " dev-restart Restart services"
echo " dev-logs Tail app logs"
echo " dev-logs-db Tail database logs"
echo " dev-shell Shell into app container"
echo " dev-artisan <cmd> Run artisan command"
echo " dev-composer <cmd> Run composer command"
echo " base-build Build and push image"
echo ""
echo "Services:"
echo " app Laravel http://localhost:8000"
echo " vite HMR http://localhost:5173"
echo " db MySQL localhost:3307"
echo ""
'';
}

View file

@ -1,54 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthenticationTest extends TestCase
{
use RefreshDatabase;
public function test_login_screen_can_be_rendered()
{
$response = $this->get('/login');
$response->assertStatus(200);
}
public function test_users_can_authenticate_using_the_login_screen()
{
$user = User::factory()->create();
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
public function test_users_can_not_authenticate_with_invalid_password()
{
$user = User::factory()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
}
public function test_users_can_logout()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/logout');
$this->assertGuest();
$response->assertRedirect('/');
}
}

View file

@ -1,58 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
class EmailVerificationTest extends TestCase
{
use RefreshDatabase;
public function test_email_verification_screen_can_be_rendered()
{
$user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get('/verify-email');
$response->assertStatus(200);
}
public function test_email_can_be_verified()
{
$user = User::factory()->unverified()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
$this->assertTrue($user->fresh()->hasVerifiedEmail());
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
}
public function test_email_is_not_verified_with_invalid_hash()
{
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1('wrong-email')]
);
$this->actingAs($user)->get($verificationUrl);
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
}

View file

@ -1,44 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/confirm-password');
$response->assertStatus(200);
}
public function test_password_can_be_confirmed()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'password',
]);
$response->assertRedirect();
$response->assertSessionHasNoErrors();
}
public function test_password_is_not_confirmed_with_invalid_password()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/confirm-password', [
'password' => 'wrong-password',
]);
$response->assertSessionHasErrors();
}
}

View file

@ -1,73 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
public function test_reset_password_link_screen_can_be_rendered()
{
$response = $this->get('/forgot-password');
$response->assertStatus(200);
}
public function test_reset_password_link_can_be_requested()
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered()
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get('/reset-password/'.$notification->token);
$response->assertStatus(200);
return true;
});
}
public function test_password_can_be_reset_with_valid_token()
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->post('/reset-password', [
'token' => $notification->token,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login'));
return true;
});
}
}

View file

@ -1,31 +0,0 @@
<?php
namespace Tests\Feature\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_registration_screen_can_be_rendered()
{
$response = $this->get('/register');
$response->assertStatus(200);
}
public function test_new_users_can_register()
{
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_guests_are_redirected_to_the_login_page()
{
$this->get('/dashboard')->assertRedirect('/login');
}
public function test_authenticated_users_can_visit_the_dashboard()
{
$this->actingAs($user = User::factory()->create());
$this->get('/dashboard')->assertOk();
}
}

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,16 +12,28 @@ 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'
'description' => 'First milestone',
]);
$this->assertDatabaseHas('milestones', [
'target' => 1500,
'description' => 'First milestone'
'description' => 'First milestone',
]);
$this->assertEquals(1500, $milestone->target);
@ -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');
@ -38,16 +52,16 @@ public function test_can_fetch_milestones_via_api(): void
$response->assertJsonCount(2);
$response->assertJson([
['target' => 1500, 'description' => 'First milestone'],
['target' => 3000, 'description' => 'Second milestone']
['target' => 3000, 'description' => 'Second milestone'],
]);
}
public function test_milestones_ordered_by_target(): void
{
// Create milestones in reverse order
Milestone::create(['target' => 3000, 'description' => 'Third']);
Milestone::create(['target' => 1000, 'description' => 'First']);
Milestone::create(['target' => 2000, 'description' => 'Second']);
$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');

View file

@ -1,51 +0,0 @@
<?php
namespace Tests\Feature\Settings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class PasswordUpdateTest extends TestCase
{
use RefreshDatabase;
public function test_password_can_be_updated()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/settings/password')
->put('/settings/password', [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/settings/password');
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
}
public function test_correct_password_must_be_provided_to_update_password()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/settings/password')
->put('/settings/password', [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
]);
$response
->assertSessionHasErrors('current_password')
->assertRedirect('/settings/password');
}
}

View file

@ -1,99 +0,0 @@
<?php
namespace Tests\Feature\Settings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProfileUpdateTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_is_displayed()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get('/settings/profile');
$response->assertOk();
}
public function test_profile_information_can_be_updated()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/settings/profile', [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/settings/profile');
$user->refresh();
$this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
}
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/settings/profile', [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/settings/profile');
$this->assertNotNull($user->refresh()->email_verified_at);
}
public function test_user_can_delete_their_account()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->delete('/settings/profile', [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/');
$this->assertGuest();
$this->assertNull($user->fresh());
}
public function test_correct_password_must_be_provided_to_delete_account()
{
$user = User::factory()->create();
$response = $this
->actingAs($user)
->from('/settings/profile')
->delete('/settings/profile', [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrors('password')
->assertRedirect('/settings/profile');
$this->assertNotNull($user->fresh());
}
}

View file

@ -5,6 +5,18 @@ import { resolve } from 'node:path';
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173,
hmr: {
host: 'localhost',
clientPort: 5173,
},
watch: {
usePolling: true,
ignored: ['**/storage/framework/views/**'],
},
},
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx'],