Compare commits

...

10 commits

45 changed files with 723 additions and 867 deletions

View file

@ -40,13 +40,6 @@ QUEUE_CONNECTION=database
CACHE_STORE=database CACHE_STORE=database
# CACHE_PREFIX= # 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_MAILER=log
MAIL_SCHEME=null MAIL_SCHEME=null
MAIL_HOST=127.0.0.1 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

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

@ -0,0 +1,141 @@
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
options: --health-cmd="mysqladmin ping -u root -proot_password" --health-interval=10s --health-timeout=5s --health-retries=10
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: 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: Set up Node.js
uses: https://data.forgejo.org/actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install JS dependencies
run: npm ci
- name: Build assets
run: npm run build

195
README.md
View file

@ -1,157 +1,108 @@
<div align="center"> <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) [![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/) [![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/) [![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> </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 ## Features
- **LED-style display**: Large red digital counter showing current share count - **LED-style display** — large red digital counter
- **Progress tracking**: Visual progress bar toward configurable milestones - **Milestone tracking** — set targets, cycle through them as you progress
- **Purchase management**: Add and track share purchases with historical data - **Purchase logging** — add entries with date and optional price
- **Financial insights**: Portfolio value and withdrawal estimates - **Price tracking** — optional; log a current price to see portfolio value and P/L
- **Milestone cycling**: Track progress toward multiple investment goals (1500→3000→4500→6000) - **Self-hosted** — your data stays on your server
## Tech Stack ## 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 - **Frontend**: React 19 + TypeScript with Inertia.js
- **Styling**: Tailwind CSS 4 with shadcn/ui components - **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 volumes:
git clone https://github.com/your-username/incr.git db_data:
cd incr
``` ```
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 ```bash
# Using Docker Compose nix-shell
docker-compose -f docker/production/docker-compose.yml up --build dev-up
# Or using Podman Compose
podman-compose -f docker/production/docker-compose.yml up --build
``` ```
The application will be available at `http://localhost:5001`. Available commands inside the shell: `dev-up`, `dev-down`, `dev-rebuild`, `dev-logs`, `dev-shell`, `dev-artisan`, `dev-composer`.
### 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
## License ## 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

@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Asset; use App\Models\Asset;
use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -19,11 +20,12 @@ public function index(): JsonResponse
public function current(): JsonResponse public function current(): JsonResponse
{ {
// Get the first/default user (since no auth) // Get the first/default user (since no auth)
$user = \App\Models\User::first(); $user = User::first();
$asset = $user ? $user->asset : null; $asset = $user ? $user->asset : null;
return response()->json([ return response()->json([
'asset' => $asset, 'asset' => $asset,
'price_tracking_enabled' => $user?->price_tracking_enabled ?? false,
]); ]);
} }
@ -40,11 +42,11 @@ public function setCurrent(Request $request)
); );
// Get or create the first/default user (since no auth) // Get or create the first/default user (since no auth)
$user = \App\Models\User::first(); $user = User::first();
if (!$user) { if (! $user) {
// Create a default user if none exists // Create a default user if none exists
$user = \App\Models\User::create([ $user = User::create([
'name' => 'Default User', 'name' => 'Default User',
'email' => 'user@example.com', 'email' => 'user@example.com',
'password' => 'password', // This will be hashed automatically 'password' => 'password', // This will be hashed automatically
@ -90,8 +92,8 @@ public function show(Asset $asset): JsonResponse
public function search(Request $request): JsonResponse public function search(Request $request): JsonResponse
{ {
$query = $request->get('q'); $query = $request->get('q');
if (!$query) { if (! $query) {
return response()->json([]); return response()->json([]);
} }
@ -103,4 +105,4 @@ public function search(Request $request): JsonResponse
return response()->json($assets); return response()->json($assets);
} }
} }

View file

@ -31,7 +31,7 @@ public function create(Request $request): Response
/** /**
* Handle an incoming new password request. * Handle an incoming new password request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {

View file

@ -6,6 +6,7 @@
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -24,7 +25,7 @@ public function create(Request $request): Response
/** /**
* Handle an incoming password reset link request. * Handle an incoming password reset link request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {

View file

@ -10,6 +10,7 @@
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -26,7 +27,7 @@ public function create(): Response
/** /**
* Handle an incoming registration request. * Handle an incoming registration request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {

View file

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -19,7 +20,7 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse
} }
if ($request->user()->markEmailAsVerified()) { if ($request->user()->markEmailAsVerified()) {
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */ /** @var MustVerifyEmail $user */
$user = $request->user(); $user = $request->user();
event(new Verified($user)); event(new Verified($user));

View file

@ -4,9 +4,9 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Milestone; use App\Models\Milestone;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class MilestoneController extends Controller class MilestoneController extends Controller
{ {
@ -31,4 +31,4 @@ public function index(): JsonResponse
return response()->json($milestones); return response()->json($milestones);
} }
} }

View file

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Pricing\AssetPrice; use App\Models\Pricing\AssetPrice;
use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -12,9 +13,9 @@ class PricingController extends Controller
public function current(): JsonResponse public function current(): JsonResponse
{ {
// Get the first/default user (since no auth) // Get the first/default user (since no auth)
$user = \App\Models\User::first(); $user = User::first();
$assetId = $user ? $user->asset_id : null; $assetId = $user ? $user->asset_id : null;
$price = AssetPrice::current($assetId); $price = AssetPrice::current($assetId);
return response()->json([ return response()->json([
@ -30,13 +31,17 @@ public function update(Request $request)
]); ]);
// Get the first/default user (since no auth) // Get the first/default user (since no auth)
$user = \App\Models\User::first(); $user = User::first();
if (!$user || !$user->asset_id) { if (! $user || ! $user->asset_id) {
return back()->withErrors(['asset' => 'Please set an asset first.']); return back()->withErrors(['asset' => 'Please set an asset first.']);
} }
$assetPrice = AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']); AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
if (! $user->price_tracking_enabled) {
$user->update(['price_tracking_enabled' => true]);
}
return back()->with('success', 'Asset price updated successfully!'); return back()->with('success', 'Asset price updated successfully!');
} }
@ -44,9 +49,9 @@ public function update(Request $request)
public function history(Request $request): JsonResponse public function history(Request $request): JsonResponse
{ {
// Get the first/default user (since no auth) // Get the first/default user (since no auth)
$user = \App\Models\User::first(); $user = User::first();
$assetId = $user ? $user->asset_id : null; $assetId = $user ? $user->asset_id : null;
$limit = $request->get('limit', 30); $limit = $request->get('limit', 30);
$history = AssetPrice::history($assetId, $limit); $history = AssetPrice::history($assetId, $limit);
@ -56,9 +61,9 @@ public function history(Request $request): JsonResponse
public function forDate(Request $request, string $date): JsonResponse public function forDate(Request $request, string $date): JsonResponse
{ {
// Get the first/default user (since no auth) // Get the first/default user (since no auth)
$user = \App\Models\User::first(); $user = User::first();
$assetId = $user ? $user->asset_id : null; $assetId = $user ? $user->asset_id : null;
$price = AssetPrice::forDate($date, $assetId); $price = AssetPrice::forDate($date, $assetId);
return response()->json([ return response()->json([

View file

@ -6,7 +6,6 @@
use App\Models\Transactions\Purchase; use App\Models\Transactions\Purchase;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
class PurchaseController extends Controller class PurchaseController extends Controller
{ {
@ -30,7 +29,7 @@ public function store(Request $request)
$calculatedTotal = $validated['shares'] * $validated['price_per_share']; $calculatedTotal = $validated['shares'] * $validated['price_per_share'];
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) { if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
return back()->withErrors([ return back()->withErrors([
'total_cost' => 'Total cost does not match shares × price per share.' 'total_cost' => 'Total cost does not match shares × price per share.',
]); ]);
} }

View file

@ -12,7 +12,7 @@ class HandleAppearance
/** /**
* Handle an incoming request. * 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 public function handle(Request $request, Closure $next): Response
{ {

View file

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

View file

@ -11,6 +11,7 @@
* @method static where(string $string, string $value) * @method static where(string $string, string $value)
* @method static find(int $id) * @method static find(int $id)
* @method static orderBy(string $string) * @method static orderBy(string $string)
*
* @property int $id * @property int $id
* @property string $symbol * @property string $symbol
* @property string|null $full_name * @property string|null $full_name

View file

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

View file

@ -46,7 +46,7 @@ public static function averageCostPerShare(): float
{ {
$totalShares = static::totalShares(); $totalShares = static::totalShares();
$totalCost = static::totalInvestment(); $totalCost = static::totalInvestment();
return $totalShares > 0 ? $totalCost / $totalShares : 0; return $totalShares > 0 ? $totalCost / $totalShares : 0;
} }
} }

View file

@ -3,17 +3,19 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Models\Transactions\Purchase;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
/** /**
* @property int $asset_id * @property int|null $asset_id
*/ */
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable;
/** /**
@ -26,6 +28,7 @@ class User extends Authenticatable
'email', 'email',
'password', 'password',
'asset_id', 'asset_id',
'price_tracking_enabled',
]; ];
/** /**
@ -43,6 +46,7 @@ protected function casts(): array
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'price_tracking_enabled' => 'boolean',
]; ];
} }
@ -53,19 +57,16 @@ public function asset(): BelongsTo
public function hasCompletedOnboarding(): bool public function hasCompletedOnboarding(): bool
{ {
// Check if user has asset, purchases, and milestones return $this->hasPurchases() && $this->hasMilestones();
return $this->asset_id !== null
&& $this->hasPurchases()
&& $this->hasMilestones();
} }
public function hasPurchases(): bool public function hasPurchases(): bool
{ {
return \App\Models\Transactions\Purchase::totalShares() > 0; return Purchase::totalShares() > 0;
} }
public function hasMilestones(): bool public function hasMilestones(): bool
{ {
return \App\Models\Milestone::count() > 0; return Milestone::count() > 0;
} }
} }

View file

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

View file

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

View file

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

View file

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

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 # Install system dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
@ -9,39 +9,21 @@ RUN apt-get update && apt-get install -y \
libxml2-dev \ libxml2-dev \
zip \ zip \
unzip \ unzip \
nodejs \
npm \
default-mysql-client \ default-mysql-client \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd && 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 # Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory # Set working directory
WORKDIR /var/www/html 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 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 RUN chmod +x /usr/local/bin/container-start.sh
EXPOSE 8000 5173 EXPOSE 8000 5173

View file

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

View file

@ -1,28 +1,16 @@
version: '3.8'
services: services:
app: app:
build: build:
context: ../../.. context: ../..
dockerfile: docker/dev/podman/Dockerfile dockerfile: docker/dev/Dockerfile
container_name: incr-dev-app container_name: incr-dev-app
restart: unless-stopped restart: unless-stopped
working_dir: /var/www/html working_dir: /var/www/html
environment: environment:
- APP_ENV=local
- APP_DEBUG=true
- APP_KEY=base64:YOUR_APP_KEY_HERE
- DB_CONNECTION=mysql
- DB_HOST=db - DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=incr_dev
- DB_USERNAME=incr_user
- DB_PASSWORD=incr_password
- VITE_PORT=5173
volumes: volumes:
- ../../../:/var/www/html:Z - ../../:/var/www/html:Z
- /var/www/html/node_modules - app_node_modules:/var/www/html/node_modules
- /var/www/html/vendor
ports: ports:
- "8000:8000" - "8000:8000"
- "5173:5173" - "5173:5173"
@ -43,6 +31,7 @@ services:
- MYSQL_ROOT_PASSWORD=root_password - MYSQL_ROOT_PASSWORD=root_password
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
- ./mysql-init:/docker-entrypoint-initdb.d:ro
ports: ports:
- "3307:3306" - "3307:3306"
healthcheck: healthcheck:
@ -54,19 +43,12 @@ services:
networks: networks:
- incr-dev-network - 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: networks:
incr-dev-network: incr-dev-network:
driver: bridge driver: bridge
volumes: volumes:
db_data: 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 # Podman aliases for Laravel Sail compatibility
# Source this file to use Sail commands with Podman # 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 # Create docker alias pointing to podman
alias docker='podman' alias docker='podman'
@ -12,10 +12,10 @@ alias docker-compose='podman-compose'
# Sail wrapper function that uses podman-compose # Sail wrapper function that uses podman-compose
sail() { sail() {
if [[ -f docker/dev/podman/docker-compose.yml ]]; then if [[ -f docker/dev/docker-compose.yml ]]; then
podman-compose -f docker/dev/podman/docker-compose.yml "$@" podman-compose -f docker/dev/docker-compose.yml "$@"
else 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 return 1
fi fi
} }

View file

@ -22,7 +22,7 @@ fi
# Start services # Start services
echo "🔧 Starting 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 # Wait for database to be ready
echo "⏳ Waiting 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 "🔥 Vite dev server: http://localhost:5173"
echo "💾 Database: localhost:3307" echo "💾 Database: localhost:3307"
echo "" echo ""
echo "To stop: podman-compose -f docker/dev/podman/docker-compose.yml down" echo "To stop: podman-compose -f docker/dev/docker-compose.yml down"
echo "To view logs: podman-compose -f docker/dev/podman/docker-compose.yml logs -f" echo "To view logs: podman-compose -f docker/dev/docker-compose.yml logs -f"

2
package-lock.json generated
View file

@ -1,5 +1,5 @@
{ {
"name": "site", "name": "html",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View file

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

View file

@ -26,6 +26,8 @@ interface StatsBoxProps {
onAddPurchase?: () => void; onAddPurchase?: () => void;
onAddMilestone?: () => void; onAddMilestone?: () => void;
onUpdatePrice?: () => void; onUpdatePrice?: () => void;
assetSymbol?: string;
priceTrackingEnabled?: boolean;
} }
export default function StatsBox({ export default function StatsBox({
@ -36,7 +38,9 @@ export default function StatsBox({
className, className,
onAddPurchase, onAddPurchase,
onAddMilestone, onAddMilestone,
onUpdatePrice onUpdatePrice,
assetSymbol,
priceTrackingEnabled = false,
}: StatsBoxProps) { }: StatsBoxProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
@ -76,9 +80,9 @@ export default function StatsBox({
<ComponentTitle>Stats</ComponentTitle> <ComponentTitle>Stats</ComponentTitle>
<div className="flex items-center space-x-2 relative"> <div className="flex items-center space-x-2 relative">
{stats.currentPrice && ( {priceTrackingEnabled && stats.currentPrice && (
<div className="text-red-500 text-sm font-mono tracking-wider"> <div className="text-red-500 text-sm font-mono tracking-wider">
VWCE: {formatCurrencyDetailed(stats.currentPrice)} {assetSymbol ?? 'PRICE'}: {formatCurrencyDetailed(stats.currentPrice)}
</div> </div>
)} )}
@ -117,7 +121,7 @@ export default function StatsBox({
ADD MILESTONE ADD MILESTONE
</button> </button>
)} )}
{onUpdatePrice && ( {priceTrackingEnabled && onUpdatePrice && (
<button <button
onClick={() => { onClick={() => {
onUpdatePrice(); onUpdatePrice();
@ -154,8 +158,8 @@ export default function StatsBox({
<tr> <tr>
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th> <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">SHARES</th>
<th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th> {priceTrackingEnabled && <th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th>}
<th className="text-right text-red-500 text-xs py-2">SWR 4%</th> {priceTrackingEnabled && <th className="text-right text-red-500 text-xs py-2">SWR 4%</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -165,12 +169,16 @@ export default function StatsBox({
<td className="text-right py-1 pr-4"> <td className="text-right py-1 pr-4">
{Math.floor(stats.totalShares).toLocaleString()} {Math.floor(stats.totalShares).toLocaleString()}
</td> </td>
<td className="text-right py-1 pr-4"> {priceTrackingEnabled && (
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'} <td className="text-right py-1 pr-4">
</td> {stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'}
<td className="text-right py-1"> </td>
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'} )}
</td> {priceTrackingEnabled && (
<td className="text-right py-1">
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'}
</td>
)}
</tr> </tr>
{/* Render milestones after current */} {/* Render milestones after current */}
@ -195,12 +203,16 @@ export default function StatsBox({
<td className="text-right py-1 pr-4"> <td className="text-right py-1 pr-4">
{Math.floor(milestone.target).toLocaleString()} {Math.floor(milestone.target).toLocaleString()}
</td> </td>
<td className="text-right py-1 pr-4"> {priceTrackingEnabled && (
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'} <td className="text-right py-1 pr-4">
</td> {stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
<td className="text-right py-1"> </td>
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'} )}
</td> {priceTrackingEnabled && (
<td className="text-right py-1">
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
</td>
)}
</tr> </tr>
); );
})} })}

View file

@ -1,9 +1,85 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import AssetSetupForm from '@/components/Assets/AssetSetupForm'; import AssetSetupForm from '@/components/Assets/AssetSetupForm';
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm'; import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm'; import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm'; import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
type TrackerType = 'simple' | 'asset';
function TrackerTypeSelector({ onSelect }: { onSelect: (type: TrackerType) => void }) {
return (
<div className="space-y-6">
<p className="text-red-400 font-mono text-sm uppercase tracking-wider">
[SELECT] What do you want to track?
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => onSelect('simple')}
className="text-left border-2 border-red-500/50 bg-black p-6 hover:bg-red-950/30 hover:border-red-400 hover:shadow-[0_0_20px_rgba(239,68,68,0.3)] transition-all"
>
<span className="block text-red-400 font-mono text-lg font-bold uppercase tracking-wider mb-3">
[01] Simple counter
</span>
<p className="text-red-400/60 font-mono text-xs">
Track anything you accumulate no price tracking, no asset setup.
</p>
</button>
<button
onClick={() => onSelect('asset')}
className="text-left border-2 border-red-500/50 bg-black p-6 hover:bg-red-950/30 hover:border-red-400 hover:shadow-[0_0_20px_rgba(239,68,68,0.3)] transition-all"
>
<span className="block text-red-400 font-mono text-lg font-bold uppercase tracking-wider mb-3">
[02] Asset tracker
</span>
<p className="text-red-400/60 font-mono text-xs">
Track holdings with price tracking and P&amp;L.
</p>
</button>
</div>
</div>
);
}
function PriceTrackingStep({ onEnable, onSkip }: { onEnable: () => void; onSkip?: () => void }) {
const [enabled, setEnabled] = useState(false);
return (
<div className="space-y-6">
<label className="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
className="w-4 h-4 accent-red-500"
/>
<span className="text-red-400 font-mono text-sm uppercase tracking-wider group-hover:text-red-300">
Enable price tracking (optional)
</span>
</label>
<p className="text-red-400/60 font-mono text-xs">
Track the current market price of your asset to see portfolio value and P&amp;L. You can enable this later in settings.
</p>
{enabled && (
<div className="border border-red-500/30 p-4">
<UpdatePriceForm onSuccess={onEnable} />
</div>
)}
{!enabled && (
<button
onClick={onSkip}
className="w-full py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 text-red-400 hover:bg-red-950/30 hover:text-red-300 transition-colors"
>
Skip and finish
</button>
)}
</div>
);
}
interface OnboardingStep { interface OnboardingStep {
id: string; id: string;
title: string; title: string;
@ -12,121 +88,81 @@ interface OnboardingStep {
required: boolean; required: boolean;
} }
const ASSET_STEPS: OnboardingStep[] = [
{ id: 'asset', title: 'SET ASSET', description: 'Choose the asset you want to track', completed: false, required: true },
{ id: 'purchases', title: 'ADD PURCHASES', description: 'Enter your current holdings', completed: false, required: true },
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
{ id: 'price', title: 'CURRENT PRICE', description: 'Set current asset price (optional)', completed: false, required: false },
];
const SIMPLE_STEPS: OnboardingStep[] = [
{ id: 'purchases', title: 'STARTING AMOUNT', description: 'Enter your starting amount', completed: false, required: true },
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
];
interface OnboardingFlowProps { interface OnboardingFlowProps {
onComplete?: () => void; onComplete?: () => void;
} }
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const [trackerType, setTrackerType] = useState<TrackerType | null>(null);
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [steps, setSteps] = useState<OnboardingStep[]>([ 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,
},
]);
// Check onboarding status on mount const checkOnboardingStatus = useCallback(async (currentSteps: OnboardingStep[]) => {
useEffect(() => {
checkOnboardingStatus();
}, []);
const checkOnboardingStatus = async () => {
try { try {
// Check asset const [purchaseData, milestonesData, assetData, priceData] = await Promise.all([
const assetResponse = await fetch('/assets/current'); fetch('/purchases/summary').then(r => r.json()),
const assetData = await assetResponse.json(); fetch('/milestones').then(r => r.json()),
const hasAsset = !!assetData.asset; fetch('/assets/current').then(r => r.json()),
fetch('/pricing/current').then(r => r.json()),
]);
// Check purchases
const purchaseResponse = await fetch('/purchases/summary');
const purchaseData = await purchaseResponse.json();
const hasPurchases = purchaseData.total_shares > 0; const hasPurchases = purchaseData.total_shares > 0;
// Check milestones
const milestonesResponse = await fetch('/milestones');
const milestonesData = await milestonesResponse.json();
const hasMilestones = milestonesData.length > 0; const hasMilestones = milestonesData.length > 0;
const hasAsset = !!assetData.asset;
// Check current price
const priceResponse = await fetch('/pricing/current');
const priceData = await priceResponse.json();
const hasPrice = !!priceData.current_price; const hasPrice = !!priceData.current_price;
setSteps(prev => prev.map(step => ({ const freshSteps = currentSteps.map(step => ({
...step, ...step,
completed: completed:
(step.id === 'asset' && hasAsset) || (step.id === 'asset' && hasAsset) ||
(step.id === 'purchases' && hasPurchases) || (step.id === 'purchases' && hasPurchases) ||
(step.id === 'milestones' && hasMilestones) || (step.id === 'milestones' && hasMilestones) ||
(step.id === 'price' && hasPrice) (step.id === 'price' && hasPrice),
}))); }));
// Find first incomplete required step setSteps(freshSteps);
const firstIncompleteStep = steps.findIndex(step =>
step.required && !step.completed const firstIncompleteRequired = freshSteps.findIndex(s => s.required && !s.completed);
);
if (firstIncompleteRequired !== -1) {
if (firstIncompleteStep !== -1) { setCurrentStep(firstIncompleteRequired);
setCurrentStep(firstIncompleteStep); } else if (onComplete) {
} else { onComplete();
// 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();
}
} }
} catch (error) { } catch (error) {
console.error('Failed to check onboarding status:', error); console.error('Failed to check onboarding status:', error);
} }
}; }, [onComplete]);
useEffect(() => {
if (trackerType === null) {
return;
}
const initialSteps = trackerType === 'simple' ? SIMPLE_STEPS : ASSET_STEPS;
setSteps(initialSteps);
setCurrentStep(0);
checkOnboardingStatus(initialSteps);
}, [trackerType, checkOnboardingStatus]);
const handleStepComplete = async () => { const handleStepComplete = async () => {
// Mark current step as completed const updatedSteps = steps.map((step, index) =>
setSteps(prev => prev.map((step, index) =>
index === currentStep ? { ...step, completed: true } : step 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
); );
setSteps(updatedSteps);
if (nextIncompleteStep !== -1) { await checkOnboardingStatus(updatedSteps);
setCurrentStep(nextIncompleteStep);
} else {
// All required steps complete
const allRequiredComplete = steps.filter(s => s.required).every(s => s.completed);
if (allRequiredComplete && onComplete) {
onComplete();
}
}
}; };
const handleStepSelect = (stepIndex: number) => { const handleStepSelect = (stepIndex: number) => {
@ -135,32 +171,19 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const renderStepContent = () => { const renderStepContent = () => {
const step = steps[currentStep]; const step = steps[currentStep];
if (!step) {
return null;
}
switch (step.id) { switch (step.id) {
case 'asset': case 'asset':
return ( return <AssetSetupForm onSuccess={handleStepComplete} />;
<AssetSetupForm
onSuccess={handleStepComplete}
/>
);
case 'purchases': case 'purchases':
return ( return <AddPurchaseForm onSuccess={handleStepComplete} />;
<AddPurchaseForm
onSuccess={handleStepComplete}
/>
);
case 'milestones': case 'milestones':
return ( return <AddMilestoneForm onSuccess={handleStepComplete} />;
<AddMilestoneForm
onSuccess={handleStepComplete}
/>
);
case 'price': case 'price':
return ( return <PriceTrackingStep onEnable={handleStepComplete} onSkip={onComplete ?? (() => {})} />;
<UpdatePriceForm
onSuccess={handleStepComplete}
/>
);
default: default:
return null; return null;
} }
@ -169,66 +192,69 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
return ( return (
<div className="min-h-screen bg-black flex items-center justify-center p-4"> <div className="min-h-screen bg-black flex items-center justify-center p-4">
<div className="w-full max-w-4xl"> <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"> <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"> <div className="mb-8">
<h1 className="text-red-400 font-mono text-2xl font-bold uppercase tracking-wider mb-2"> <h1 className="text-red-400 font-mono text-2xl font-bold uppercase tracking-wider mb-2">
[SYSTEM] ONBOARDING SEQUENCE [SYSTEM] ONBOARDING SEQUENCE
</h1> </h1>
<p className="text-red-400/60 font-mono text-sm"> <p className="text-red-400/60 font-mono text-sm">
Initialize your asset tracking system {trackerType === null ? 'Choose how you want to track' : 'Set up your tracker'}
</p> </p>
</div> </div>
{/* Progress indicator */} {trackerType === null ? (
<div className="mb-8"> <div className="border border-red-500/30 bg-black/50 p-6">
<div className="flex items-center justify-between mb-4"> <TrackerTypeSelector onSelect={setTrackerType} />
{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>
))}
</div> </div>
) : (
<div className="text-center"> <>
<p className="text-red-400 font-mono text-sm"> <div className="mb-8">
{steps[currentStep].description} <div className="flex items-center justify-between mb-4">
</p> {steps.map((step, index) => (
<p className="text-red-400/60 font-mono text-xs mt-1"> <button
STEP {currentStep + 1}/{steps.length} key={step.id}
</p> onClick={() => handleStepSelect(index)}
</div> className={`flex-1 px-4 py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 transition-all ${
</div> 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>
))}
</div>
{/* Step content */} <div className="text-center">
<div className="border border-red-500/30 bg-black/50 p-6"> <p className="text-red-400 font-mono text-sm">
{renderStepContent()} {steps[currentStep]?.description}
</div> </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="border border-red-500/30 bg-black/50 p-6">
<div className="mt-6 pt-4 border-t border-red-500/30"> {renderStepContent()}
<div className="flex justify-between items-center"> </div>
<p className="text-red-400/60 font-mono text-xs">
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE <div className="mt-6 pt-4 border-t border-red-500/30">
</p> <div className="flex justify-between items-center">
<p className="text-red-400/60 font-mono text-xs"> <p className="text-red-400/60 font-mono text-xs">
{steps.filter(s => s.required && !s.completed).length} REQUIRED REMAINING [STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
</p> </p>
</div> <p className="text-red-400/60 font-mono text-xs">
</div> {steps.filter(s => s.required && !s.completed).length} REQUIRED REMAINING
</p>
</div>
</div>
</>
)}
</div> </div>
</div> </div>
</div> </div>
); );
} }

View file

@ -42,6 +42,7 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [needsOnboarding, setNeedsOnboarding] = useState(false); const [needsOnboarding, setNeedsOnboarding] = useState(false);
const [currentAsset, setCurrentAsset] = useState<any>(null); const [currentAsset, setCurrentAsset] = useState<any>(null);
const [priceTrackingEnabled, setPriceTrackingEnabled] = useState(false);
// Fetch purchase summary, current price, milestones, and check onboarding // Fetch purchase summary, current price, milestones, and check onboarding
useEffect(() => { useEffect(() => {
@ -72,6 +73,7 @@ export default function Dashboard() {
if (assetResponse.ok) { if (assetResponse.ok) {
const assetData = await assetResponse.json(); const assetData = await assetResponse.json();
setCurrentAsset(assetData.asset); setCurrentAsset(assetData.asset);
setPriceTrackingEnabled(assetData.price_tracking_enabled ?? false);
} }
// Check if onboarding is needed after all data is loaded // Check if onboarding is needed after all data is loaded
@ -104,7 +106,7 @@ export default function Dashboard() {
const hasMilestones = milestonesData.length > 0; const hasMilestones = milestonesData.length > 0;
// User needs onboarding if any required step is missing // User needs onboarding if any required step is missing
const needsOnboarding = !hasAsset || !hasPurchases || !hasMilestones; const needsOnboarding = !hasPurchases || !hasMilestones;
setNeedsOnboarding(needsOnboarding); setNeedsOnboarding(needsOnboarding);
} catch (error) { } catch (error) {
console.error('Failed to check onboarding status:', error); console.error('Failed to check onboarding status:', error);
@ -238,6 +240,7 @@ export default function Dashboard() {
if (assetResponse.ok) { if (assetResponse.ok) {
const assetData = await assetResponse.json(); const assetData = await assetResponse.json();
setCurrentAsset(assetData.asset); setCurrentAsset(assetData.asset);
setPriceTrackingEnabled(assetData.price_tracking_enabled ?? false);
} }
}; };
@ -253,7 +256,7 @@ export default function Dashboard() {
return ( return (
<> <>
<Head title="VWCE Tracker" /> <Head title="incr" />
{/* Stacked Layout */} {/* Stacked Layout */}
<div className="min-h-screen bg-black"> <div className="min-h-screen bg-black">
@ -286,6 +289,8 @@ export default function Dashboard() {
onAddPurchase={() => setActiveForm('purchase')} onAddPurchase={() => setActiveForm('purchase')}
onAddMilestone={() => setActiveForm('milestone')} onAddMilestone={() => setActiveForm('milestone')}
onUpdatePrice={() => setActiveForm('price')} onUpdatePrice={() => setActiveForm('price')}
assetSymbol={currentAsset?.symbol}
priceTrackingEnabled={priceTrackingEnabled}
/> />
</div> </div>

View file

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

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

@ -14,12 +14,12 @@ public function test_can_create_milestone(): void
{ {
$milestone = Milestone::create([ $milestone = Milestone::create([
'target' => 1500, 'target' => 1500,
'description' => 'First milestone' 'description' => 'First milestone',
]); ]);
$this->assertDatabaseHas('milestones', [ $this->assertDatabaseHas('milestones', [
'target' => 1500, 'target' => 1500,
'description' => 'First milestone' 'description' => 'First milestone',
]); ]);
$this->assertEquals(1500, $milestone->target); $this->assertEquals(1500, $milestone->target);
@ -38,7 +38,7 @@ public function test_can_fetch_milestones_via_api(): void
$response->assertJsonCount(2); $response->assertJsonCount(2);
$response->assertJson([ $response->assertJson([
['target' => 1500, 'description' => 'First milestone'], ['target' => 1500, 'description' => 'First milestone'],
['target' => 3000, 'description' => 'Second milestone'] ['target' => 3000, 'description' => 'Second milestone'],
]); ]);
} }

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'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173,
hmr: {
host: 'localhost',
clientPort: 5173,
},
watch: {
usePolling: true,
ignored: ['**/storage/framework/views/**'],
},
},
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx'], input: ['resources/css/app.css', 'resources/js/app.tsx'],