Add chart color scheme setting (#101)
## Summary - Add a new **Chart color scheme** dropdown in Settings > Appearance allowing users to choose between 4 color palettes: **Colorful** (new default), **Neutral** (previous zinc grayscale), **Blue**, and **Pink** - Persist the setting in a new `user_settings` table with instant client-side feedback via localStorage + cookie - All charts across dashboard, categories, budgets, and cashflow update instantly when switching schemes - Reduced colorful palette intensity (shifted from 500/600 to 300/400 range) for lower contrast
This commit is contained in:
parent
79dd24b23e
commit
0c5ba916bf
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ChartColorScheme: string
|
||||
{
|
||||
case Colorful = 'colorful';
|
||||
case Neutral = 'neutral';
|
||||
case Blue = 'blue';
|
||||
case Pink = 'pink';
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\UpdateChartColorSchemeRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class ChartColorSchemeController extends Controller
|
||||
{
|
||||
public function update(UpdateChartColorSchemeRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->setting()->updateOrCreate(
|
||||
['user_id' => $request->user()->id],
|
||||
['chart_color_scheme' => $request->validated('chart_color_scheme')]
|
||||
);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ class HandleAppearance
|
|||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||
View::share('chartColorScheme', $request->cookie('chart-color-scheme') ?? 'colorful');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ class HandleInertiaRequests extends Middleware
|
|||
'bestValuePlan' => config('subscriptions.best_value_plan', null),
|
||||
'promo' => config('subscriptions.promo', []),
|
||||
],
|
||||
'chartColorScheme' => $user?->setting?->chart_color_scheme?->value ?? 'colorful',
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'features' => [
|
||||
'cashflow' => true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Enums\ChartColorScheme;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateChartColorSchemeRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'chart_color_scheme' => ['required', 'string', Rule::enum(ChartColorScheme::class)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +69,11 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
return $this->onboarded_at !== null;
|
||||
}
|
||||
|
||||
public function setting(): HasOne
|
||||
{
|
||||
return $this->hasOne(UserSetting::class);
|
||||
}
|
||||
|
||||
public function encryptedMessage(): HasOne
|
||||
{
|
||||
return $this->hasOne(EncryptedMessage::class);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ChartColorScheme;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserSetting extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserSettingFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'chart_color_scheme',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'chart_color_scheme' => ChartColorScheme::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'chart-color-scheme']);
|
||||
|
||||
$middleware->trustProxies(
|
||||
at: '*',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\ChartColorScheme;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserSetting>
|
||||
*/
|
||||
class UserSettingFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'chart_color_scheme' => ChartColorScheme::Colorful,
|
||||
];
|
||||
}
|
||||
|
||||
public function withScheme(ChartColorScheme $scheme): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'chart_color_scheme' => $scheme,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_settings', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->unique()->constrained()->cascadeOnDelete();
|
||||
$table->string('chart_color_scheme')->default('colorful');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_settings');
|
||||
}
|
||||
};
|
||||
|
|
@ -157,6 +157,111 @@
|
|||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
/* Colorful chart color scheme */
|
||||
[data-chart-color='colorful'] {
|
||||
--chart-1: var(--color-blue-600);
|
||||
--chart-2: var(--color-emerald-500);
|
||||
--chart-3: var(--color-amber-500);
|
||||
--chart-4: var(--color-rose-500);
|
||||
--chart-5: var(--color-violet-500);
|
||||
--chart-6: var(--color-cyan-500);
|
||||
--chart-7: var(--color-orange-500);
|
||||
--chart-8: var(--color-teal-500);
|
||||
--chart-9: var(--color-pink-400);
|
||||
--chart-10: var(--color-indigo-400);
|
||||
--spent: var(--color-blue-600);
|
||||
--allocated: var(--color-blue-200);
|
||||
--spent-prev: var(--color-blue-400);
|
||||
--account-line: var(--color-zinc-800);
|
||||
--cashflow-income: var(--color-emerald-500);
|
||||
--cashflow-expense: var(--color-red-500);
|
||||
}
|
||||
|
||||
.dark[data-chart-color='colorful'] {
|
||||
--chart-1: var(--color-blue-400);
|
||||
--chart-2: var(--color-emerald-400);
|
||||
--chart-3: var(--color-amber-400);
|
||||
--chart-4: var(--color-rose-400);
|
||||
--chart-5: var(--color-violet-400);
|
||||
--chart-6: var(--color-cyan-400);
|
||||
--chart-7: var(--color-orange-400);
|
||||
--chart-8: var(--color-teal-400);
|
||||
--chart-9: var(--color-pink-300);
|
||||
--chart-10: var(--color-indigo-300);
|
||||
--spent: var(--color-blue-400);
|
||||
--allocated: var(--color-blue-800);
|
||||
--spent-prev: var(--color-blue-600);
|
||||
--account-line: var(--color-zinc-50);
|
||||
--cashflow-income: var(--color-emerald-400);
|
||||
--cashflow-expense: var(--color-red-400);
|
||||
}
|
||||
|
||||
/* Blue chart color scheme */
|
||||
[data-chart-color='blue'] {
|
||||
--chart-1: var(--color-blue-900);
|
||||
--chart-2: var(--color-blue-800);
|
||||
--chart-3: var(--color-blue-700);
|
||||
--chart-4: var(--color-blue-600);
|
||||
--chart-5: var(--color-blue-500);
|
||||
--chart-6: var(--color-blue-400);
|
||||
--chart-7: var(--color-blue-300);
|
||||
--chart-8: var(--color-blue-200);
|
||||
--chart-9: var(--color-blue-100);
|
||||
--chart-10: var(--color-blue-50);
|
||||
--spent: var(--color-blue-800);
|
||||
--allocated: var(--color-blue-200);
|
||||
--spent-prev: var(--color-blue-400);
|
||||
}
|
||||
|
||||
.dark[data-chart-color='blue'] {
|
||||
--chart-1: var(--color-blue-200);
|
||||
--chart-2: var(--color-blue-300);
|
||||
--chart-3: var(--color-blue-400);
|
||||
--chart-4: var(--color-blue-500);
|
||||
--chart-5: var(--color-blue-600);
|
||||
--chart-6: var(--color-blue-700);
|
||||
--chart-7: var(--color-blue-800);
|
||||
--chart-8: var(--color-blue-900);
|
||||
--chart-9: var(--color-blue-50);
|
||||
--chart-10: var(--color-blue-100);
|
||||
--spent: var(--color-blue-200);
|
||||
--allocated: var(--color-blue-700);
|
||||
--spent-prev: var(--color-blue-500);
|
||||
}
|
||||
|
||||
/* Pink chart color scheme */
|
||||
[data-chart-color='pink'] {
|
||||
--chart-1: var(--color-pink-900);
|
||||
--chart-2: var(--color-pink-800);
|
||||
--chart-3: var(--color-pink-700);
|
||||
--chart-4: var(--color-pink-600);
|
||||
--chart-5: var(--color-pink-500);
|
||||
--chart-6: var(--color-pink-400);
|
||||
--chart-7: var(--color-pink-300);
|
||||
--chart-8: var(--color-pink-200);
|
||||
--chart-9: var(--color-pink-100);
|
||||
--chart-10: var(--color-pink-50);
|
||||
--spent: var(--color-pink-800);
|
||||
--allocated: var(--color-pink-200);
|
||||
--spent-prev: var(--color-pink-400);
|
||||
}
|
||||
|
||||
.dark[data-chart-color='pink'] {
|
||||
--chart-1: var(--color-pink-200);
|
||||
--chart-2: var(--color-pink-300);
|
||||
--chart-3: var(--color-pink-400);
|
||||
--chart-4: var(--color-pink-500);
|
||||
--chart-5: var(--color-pink-600);
|
||||
--chart-6: var(--color-pink-700);
|
||||
--chart-7: var(--color-pink-800);
|
||||
--chart-8: var(--color-pink-900);
|
||||
--chart-9: var(--color-pink-50);
|
||||
--chart-10: var(--color-pink-100);
|
||||
--spent: var(--color-pink-200);
|
||||
--allocated: var(--color-pink-700);
|
||||
--spent-prev: var(--color-pink-500);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { EncryptionKeyProvider } from './contexts/encryption-key-context';
|
|||
import { PrivacyModeProvider } from './contexts/privacy-mode-context';
|
||||
import { SyncProvider } from './contexts/sync-context';
|
||||
import { initializeTheme } from './hooks/use-appearance';
|
||||
import { initializeChartColorScheme } from './hooks/use-chart-color-scheme';
|
||||
import { initializePostHog } from './lib/posthog';
|
||||
import type { SharedData } from './types';
|
||||
import { setTranslations } from './utils/i18n';
|
||||
|
|
@ -33,6 +34,7 @@ initializePostHog();
|
|||
|
||||
// Initialize theme before creating the app so progress bar color is correct
|
||||
initializeTheme();
|
||||
initializeChartColorScheme();
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { BankLogo } from '@/components/bank-logo';
|
|||
import { AmountTrendIndicator } from '@/components/dashboard/amount-trend-indicator';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
|
||||
import { supportsInvestedAmount } from '@/types/account';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
|
@ -24,6 +25,7 @@ export function AccountListCard({
|
|||
loading,
|
||||
onBalanceUpdated,
|
||||
}: AccountListCardProps) {
|
||||
const { accountMainLineColor, accountGainLineColor } = useChartColors();
|
||||
const [updateBalanceOpen, setUpdateBalanceOpen] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -225,7 +227,7 @@ export function AccountListCard({
|
|||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="var(--color-chart-2)"
|
||||
stroke={accountMainLineColor}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
|
|
@ -233,7 +235,7 @@ export function AccountListCard({
|
|||
<Line
|
||||
type="monotone"
|
||||
dataKey="investedAmount"
|
||||
stroke="var(--color-chart-6)"
|
||||
stroke={accountGainLineColor}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { BreakdownData } from '@/hooks/use-cashflow-data';
|
||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getCategoryColorClasses } from '@/types/category';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
|
@ -22,23 +23,13 @@ interface BreakdownCardProps {
|
|||
currency?: string;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
'var(--chart-6)',
|
||||
'var(--chart-7)',
|
||||
'var(--chart-8)',
|
||||
];
|
||||
|
||||
export function BreakdownCard({
|
||||
type,
|
||||
data,
|
||||
loading,
|
||||
currency = 'USD',
|
||||
}: BreakdownCardProps) {
|
||||
const { categoryBarColor } = useChartColors();
|
||||
const title =
|
||||
type === 'income' ? __('Income Sources') : __('Expense Categories');
|
||||
const description =
|
||||
|
|
@ -107,8 +98,10 @@ export function BreakdownCard({
|
|||
const categoryColor = getCategoryColorClasses(
|
||||
item.category.color,
|
||||
);
|
||||
const chartColor =
|
||||
CHART_COLORS[index % CHART_COLORS.length];
|
||||
const chartColor = categoryBarColor(
|
||||
item.category.color,
|
||||
index,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={item.category_id} className="space-y-1.5">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from '@/components/ui/card';
|
||||
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
||||
import { TrendDataPoint } from '@/hooks/use-cashflow-data';
|
||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatCompactNumber, formatMonthFromYearMonth } from '@/utils/date';
|
||||
|
|
@ -43,12 +44,16 @@ interface CustomTooltipProps {
|
|||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
currency?: string;
|
||||
incomeColor?: string;
|
||||
expenseColor?: string;
|
||||
}
|
||||
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
currency = 'USD',
|
||||
incomeColor,
|
||||
expenseColor,
|
||||
}: CustomTooltipProps) {
|
||||
const locale = useLocale();
|
||||
|
||||
|
|
@ -65,7 +70,10 @@ function CustomTooltip({
|
|||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full bg-[var(--color-chart-2)]" />
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ background: incomeColor }}
|
||||
/>
|
||||
{__('Income')}
|
||||
</span>
|
||||
<AmountDisplay
|
||||
|
|
@ -78,7 +86,10 @@ function CustomTooltip({
|
|||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full bg-[var(--color-chart-5)]" />
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ background: expenseColor }}
|
||||
/>
|
||||
{__('Expenses')}
|
||||
</span>
|
||||
<AmountDisplay
|
||||
|
|
@ -119,15 +130,16 @@ export function CashflowTrendChart({
|
|||
const locale = useLocale();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const minChartWidth = data.length * 60;
|
||||
const { cashflowIncomeColor, cashflowExpenseColor } = useChartColors();
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
income: {
|
||||
label: __('Income'),
|
||||
color: 'var(--color-chart-2)',
|
||||
color: cashflowIncomeColor,
|
||||
},
|
||||
expense: {
|
||||
label: __('Expenses'),
|
||||
color: 'var(--color-chart-5)',
|
||||
color: cashflowExpenseColor,
|
||||
},
|
||||
net: {
|
||||
label: __('Net'),
|
||||
|
|
@ -214,7 +226,13 @@ export function CashflowTrendChart({
|
|||
/>
|
||||
|
||||
<Tooltip
|
||||
content={<CustomTooltip currency={currency} />}
|
||||
content={
|
||||
<CustomTooltip
|
||||
currency={currency}
|
||||
incomeColor={cashflowIncomeColor}
|
||||
expenseColor={cashflowExpenseColor}
|
||||
/>
|
||||
}
|
||||
cursor={{
|
||||
fill: 'var(--color-muted)',
|
||||
opacity: 0.3,
|
||||
|
|
@ -223,7 +241,7 @@ export function CashflowTrendChart({
|
|||
|
||||
<Bar
|
||||
dataKey="income"
|
||||
fill="var(--color-chart-2)"
|
||||
fill={cashflowIncomeColor}
|
||||
radius={[4, 4, 0, 0]}
|
||||
stackId="a"
|
||||
name="Income"
|
||||
|
|
@ -231,7 +249,7 @@ export function CashflowTrendChart({
|
|||
|
||||
<Bar
|
||||
dataKey="expense"
|
||||
fill="var(--color-chart-5)"
|
||||
fill={cashflowExpenseColor}
|
||||
radius={[4, 4, 0, 0]}
|
||||
stackId="b"
|
||||
name="Expenses"
|
||||
|
|
@ -255,11 +273,17 @@ export function CashflowTrendChart({
|
|||
</div>
|
||||
<div className="mt-4 flex items-center justify-center gap-6 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-3 rounded bg-[var(--color-chart-2)]" />
|
||||
<span
|
||||
className="size-3 rounded"
|
||||
style={{ background: cashflowIncomeColor }}
|
||||
/>
|
||||
<span>{__('Income')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-3 rounded bg-[var(--color-chart-5)]" />
|
||||
<span
|
||||
className="size-3 rounded"
|
||||
style={{ background: cashflowExpenseColor }}
|
||||
/>
|
||||
<span>{__('Expenses')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog
|
|||
import { BankLogo } from '@/components/bank-logo';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
|
||||
import { supportsInvestedAmount } from '@/types/account';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
|
@ -24,6 +25,7 @@ export function AccountBalanceCard({
|
|||
loading,
|
||||
onBalanceUpdated,
|
||||
}: AccountBalanceCardProps) {
|
||||
const { accountMainLineColor, accountGainLineColor } = useChartColors();
|
||||
const [updateBalanceOpen, setUpdateBalanceOpen] = useState(false);
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -210,7 +212,7 @@ export function AccountBalanceCard({
|
|||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={'var(--color-chart-2)'}
|
||||
stroke={accountMainLineColor}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
|
|
@ -218,7 +220,7 @@ export function AccountBalanceCard({
|
|||
<Line
|
||||
type="monotone"
|
||||
dataKey="investedAmount"
|
||||
stroke="var(--color-chart-6)"
|
||||
stroke={accountGainLineColor}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedData } from '@/types';
|
||||
import { Category, getCategoryColorClasses } from '@/types/category';
|
||||
|
|
@ -28,24 +29,12 @@ interface TopCategoriesCardProps {
|
|||
loading?: boolean;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
'var(--chart-6)',
|
||||
'var(--chart-7)',
|
||||
'var(--chart-7)',
|
||||
'var(--chart-8)',
|
||||
'var(--chart-8)',
|
||||
];
|
||||
|
||||
export function TopCategoriesCard({
|
||||
categories,
|
||||
loading,
|
||||
}: TopCategoriesCardProps) {
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
const { categoryBarColor } = useChartColors();
|
||||
|
||||
if (loading || !auth?.user) {
|
||||
return (
|
||||
|
|
@ -97,8 +86,10 @@ export function TopCategoriesCard({
|
|||
const categoryColor = getCategoryColorClasses(
|
||||
item.category.color,
|
||||
);
|
||||
const chartColor =
|
||||
CHART_COLORS[index % CHART_COLORS.length];
|
||||
const chartColor = categoryBarColor(
|
||||
item.category.color,
|
||||
index,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={item.category.id} className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import { ChartColorScheme, SharedData } from '@/types';
|
||||
import { CategoryColor, getCategoryChartColor } from '@/types/category';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'chart-color-scheme';
|
||||
|
||||
const setCookie = (name: string, value: string, days = 365) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
};
|
||||
|
||||
const applyColorScheme = (scheme: ChartColorScheme) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheme === 'neutral') {
|
||||
document.documentElement.removeAttribute('data-chart-color');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-chart-color', scheme);
|
||||
}
|
||||
};
|
||||
|
||||
export function initializeChartColorScheme() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const saved =
|
||||
(localStorage.getItem(STORAGE_KEY) as ChartColorScheme) || 'colorful';
|
||||
applyColorScheme(saved);
|
||||
}
|
||||
|
||||
export function useChartColorScheme() {
|
||||
const { chartColorScheme: serverScheme } = usePage<SharedData>().props;
|
||||
const [scheme, setScheme] = useState<ChartColorScheme>('colorful');
|
||||
|
||||
const updateScheme = useCallback((newScheme: ChartColorScheme) => {
|
||||
setScheme(newScheme);
|
||||
localStorage.setItem(STORAGE_KEY, newScheme);
|
||||
setCookie(STORAGE_KEY, newScheme);
|
||||
applyColorScheme(newScheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(
|
||||
STORAGE_KEY,
|
||||
) as ChartColorScheme | null;
|
||||
updateScheme(saved || serverScheme || 'colorful');
|
||||
}, [serverScheme, updateScheme]);
|
||||
|
||||
return { scheme, updateScheme } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns color helpers derived from the active chart color scheme.
|
||||
*
|
||||
* - `accountMainLineColor` – stroke for the main balance line in account cards
|
||||
* - `accountGainLineColor` – stroke for the invested/gain line in account cards
|
||||
* - `categoryBarColor` – progress bar color for a given category (colorful
|
||||
* uses the category's own color; other schemes cycle
|
||||
* through --chart-* variables by index)
|
||||
*/
|
||||
export function useChartColors() {
|
||||
const { chartColorScheme } = usePage<SharedData>().props;
|
||||
const isColorful = chartColorScheme === 'colorful';
|
||||
|
||||
const accountMainLineColor = isColorful
|
||||
? 'var(--account-line)'
|
||||
: 'var(--color-chart-2)';
|
||||
|
||||
const accountGainLineColor = isColorful
|
||||
? 'var(--color-emerald-500)'
|
||||
: 'var(--color-chart-6)';
|
||||
|
||||
const cashflowIncomeColor = isColorful
|
||||
? 'var(--cashflow-income)'
|
||||
: 'var(--color-chart-2)';
|
||||
|
||||
const cashflowExpenseColor = isColorful
|
||||
? 'var(--cashflow-expense)'
|
||||
: 'var(--color-chart-5)';
|
||||
|
||||
const CHART_COLORS = [
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
'var(--chart-6)',
|
||||
'var(--chart-7)',
|
||||
'var(--chart-8)',
|
||||
];
|
||||
|
||||
const categoryBarColor = (color: CategoryColor, index: number): string =>
|
||||
isColorful
|
||||
? getCategoryChartColor(color)
|
||||
: CHART_COLORS[index % CHART_COLORS.length];
|
||||
|
||||
return {
|
||||
accountMainLineColor,
|
||||
accountGainLineColor,
|
||||
cashflowIncomeColor,
|
||||
cashflowExpenseColor,
|
||||
categoryBarColor,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,13 +1,20 @@
|
|||
import { __ } from '@/utils/i18n';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
|
||||
import AppearanceTabs from '@/components/appearance-tabs';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useChartColorScheme } from '@/hooks/use-chart-color-scheme';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { type BreadcrumbItem, type ChartColorScheme } from '@/types';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
|
|
@ -16,7 +23,27 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const schemes: { value: ChartColorScheme; label: string }[] = [
|
||||
{ value: 'colorful', label: 'Colorful' },
|
||||
{ value: 'neutral', label: 'Neutral' },
|
||||
{ value: 'blue', label: 'Blue' },
|
||||
{ value: 'pink', label: 'Pink' },
|
||||
];
|
||||
|
||||
export default function Appearance() {
|
||||
const { scheme, updateScheme } = useChartColorScheme();
|
||||
|
||||
const handleSchemeChange = (value: string) => {
|
||||
const newScheme = value as ChartColorScheme;
|
||||
updateScheme(newScheme);
|
||||
|
||||
router.patch(
|
||||
'/settings/chart-color-scheme',
|
||||
{ chart_color_scheme: newScheme },
|
||||
{ preserveScroll: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title={__('Appearance settings')} />
|
||||
|
|
@ -32,6 +59,25 @@ export default function Appearance() {
|
|||
|
||||
<AppearanceTabs />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<HeadingSmall
|
||||
title="Chart color scheme"
|
||||
description="Choose the color palette for your charts"
|
||||
/>
|
||||
<Select value={scheme} onValueChange={handleSchemeChange}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schemes.map(({ value, label }) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -204,3 +204,30 @@ export function getCategoryColorClasses(color: CategoryColor): {
|
|||
|
||||
return colorMap[color];
|
||||
}
|
||||
|
||||
export function getCategoryChartColor(color: CategoryColor): string {
|
||||
const colorMap: Record<CategoryColor, string> = {
|
||||
amber: 'var(--color-amber-500)',
|
||||
blue: 'var(--color-blue-500)',
|
||||
cyan: 'var(--color-cyan-500)',
|
||||
emerald: 'var(--color-emerald-500)',
|
||||
fuchsia: 'var(--color-fuchsia-500)',
|
||||
gray: 'var(--color-gray-500)',
|
||||
green: 'var(--color-green-500)',
|
||||
indigo: 'var(--color-indigo-500)',
|
||||
lime: 'var(--color-lime-500)',
|
||||
neutral: 'var(--color-neutral-500)',
|
||||
orange: 'var(--color-orange-500)',
|
||||
pink: 'var(--color-pink-500)',
|
||||
purple: 'var(--color-purple-500)',
|
||||
red: 'var(--color-red-500)',
|
||||
rose: 'var(--color-rose-500)',
|
||||
slate: 'var(--color-slate-500)',
|
||||
stone: 'var(--color-stone-500)',
|
||||
teal: 'var(--color-teal-500)',
|
||||
violet: 'var(--color-violet-500)',
|
||||
yellow: 'var(--color-yellow-500)',
|
||||
};
|
||||
|
||||
return colorMap[color] ?? 'var(--color-gray-500)';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export interface Flash {
|
|||
error: string | null;
|
||||
}
|
||||
|
||||
export type ChartColorScheme = 'neutral' | 'colorful' | 'blue' | 'pink';
|
||||
|
||||
export interface SharedData {
|
||||
name: string;
|
||||
appUrl: string;
|
||||
|
|
@ -56,6 +58,7 @@ export interface SharedData {
|
|||
quote: { message: string; author: string };
|
||||
auth: Auth;
|
||||
flash: Flash;
|
||||
chartColorScheme: ChartColorScheme;
|
||||
subscriptionsEnabled: boolean;
|
||||
pricing: PricingConfig;
|
||||
sidebarOpen: boolean;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@
|
|||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
var chartScheme = localStorage.getItem('chart-color-scheme') || '{{ $chartColorScheme ?? "colorful" }}';
|
||||
if (chartScheme && chartScheme !== 'neutral') {
|
||||
document.documentElement.setAttribute('data-chart-color', chartScheme);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use App\Http\Controllers\OpenBanking\ConnectionController;
|
|||
use App\Http\Controllers\Settings\AccountController;
|
||||
use App\Http\Controllers\Settings\BankController;
|
||||
use App\Http\Controllers\Settings\CategoryController;
|
||||
use App\Http\Controllers\Settings\ChartColorSchemeController;
|
||||
use App\Http\Controllers\Settings\LabelController;
|
||||
use App\Http\Controllers\Settings\PasswordController;
|
||||
use App\Http\Controllers\Settings\ProfileController;
|
||||
|
|
@ -55,6 +56,9 @@ Route::middleware('auth')->group(function () {
|
|||
return Inertia::render('settings/appearance');
|
||||
})->name('appearance.edit');
|
||||
|
||||
Route::patch('settings/chart-color-scheme', [ChartColorSchemeController::class, 'update'])
|
||||
->name('chart-color-scheme.update');
|
||||
|
||||
Route::get('settings/billing', [SubscriptionController::class, 'billing'])->name('settings.billing');
|
||||
Route::get('settings/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('settings.billing.portal');
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ChartColorScheme;
|
||||
use App\Models\User;
|
||||
use App\Models\UserSetting;
|
||||
|
||||
test('chart color scheme can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch(route('chart-color-scheme.update'), [
|
||||
'chart_color_scheme' => 'blue',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors()->assertRedirect();
|
||||
|
||||
expect($user->fresh()->setting->chart_color_scheme)->toBe(ChartColorScheme::Blue);
|
||||
});
|
||||
|
||||
test('chart color scheme rejects invalid values', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch(route('chart-color-scheme.update'), [
|
||||
'chart_color_scheme' => 'rainbow',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('chart_color_scheme');
|
||||
});
|
||||
|
||||
test('chart color scheme requires authentication', function () {
|
||||
$response = $this->patch(route('chart-color-scheme.update'), [
|
||||
'chart_color_scheme' => 'blue',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('chart color scheme creates setting when none exists', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect(UserSetting::where('user_id', $user->id)->exists())->toBeFalse();
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('chart-color-scheme.update'), [
|
||||
'chart_color_scheme' => 'pink',
|
||||
]);
|
||||
|
||||
expect(UserSetting::where('user_id', $user->id)->exists())->toBeTrue();
|
||||
expect($user->fresh()->setting->chart_color_scheme)->toBe(ChartColorScheme::Pink);
|
||||
});
|
||||
|
||||
test('chart color scheme updates existing setting', function () {
|
||||
$user = User::factory()->create();
|
||||
UserSetting::factory()->for($user)->withScheme(ChartColorScheme::Blue)->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('chart-color-scheme.update'), [
|
||||
'chart_color_scheme' => 'neutral',
|
||||
]);
|
||||
|
||||
expect($user->fresh()->setting->chart_color_scheme)->toBe(ChartColorScheme::Neutral);
|
||||
});
|
||||
|
||||
test('chart color scheme defaults to colorful when no setting exists', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect($user->setting)->toBeNull();
|
||||
expect($user->setting?->chart_color_scheme?->value ?? 'colorful')->toBe('colorful');
|
||||
});
|
||||
|
||||
test('chart color scheme is shared via inertia', function () {
|
||||
$user = User::factory()->create();
|
||||
UserSetting::factory()->for($user)->withScheme(ChartColorScheme::Pink)->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('appearance.edit'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page->where('chartColorScheme', 'pink'));
|
||||
});
|
||||
Loading…
Reference in New Issue