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:
Víctor Falcón 2026-02-28 11:58:21 +00:00 committed by GitHub
parent 79dd24b23e
commit 0c5ba916bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 597 additions and 45 deletions

View File

@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum ChartColorScheme: string
{
case Colorful = 'colorful';
case Neutral = 'neutral';
case Blue = 'blue';
case Pink = 'pink';
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

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

View File

@ -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)],
];
}
}

View File

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

View File

@ -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);
}
}

View File

@ -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: '*',

View File

@ -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,
]);
}
}

View File

@ -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');
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
};
}

View File

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

View File

@ -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)';
}

View File

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

View File

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

View File

@ -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');

View File

@ -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'));
});