v1.1.0 Kalendarz i listy zakupów
All checks were successful
Build and Push Docker Images / build-and-push (push) Successful in 3m10s

Dodaje wspólny kalendarz wydarzeń grupy (zakres dat/godzin, cały dzień,
notatki, lokalizacja, uczestnicy z gospodarstwa + goście zewnętrzni)
oraz listy zakupów (wiele list, dodawanie/edycja/odznaczanie/usuwanie
produktów). Nowe tabele w schemacie SQLite są czysto addytywne
(CREATE TABLE IF NOT EXISTS), więc nie ruszają istniejących danych.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 23:26:00 +02:00
parent 350ccdbaf7
commit 9c39b33636
28 changed files with 1537 additions and 9 deletions

View File

@@ -14,6 +14,9 @@ import History from './pages/History.jsx';
import Settlements from './pages/Settlements.jsx';
import Stats from './pages/Stats.jsx';
import Settings from './pages/Settings.jsx';
import Calendar from './pages/Calendar.jsx';
import ShoppingLists from './pages/ShoppingLists.jsx';
import ShoppingListDetail from './pages/ShoppingListDetail.jsx';
import BottomNav from './components/BottomNav.jsx';
import OfflineBanner from './components/OfflineBanner.jsx';
import InstallBanner from './components/InstallBanner.jsx';
@@ -65,6 +68,9 @@ export default function App() {
<Route path="/settlements" element={<Settlements />} />
<Route path="/stats" element={<Stats />} />
<Route path="/settings" element={<Settings />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/shopping" element={<ShoppingLists />} />
<Route path="/shopping/:id" element={<ShoppingListDetail />} />
</Route>
</Route>
</Route>

View File

@@ -225,6 +225,122 @@ export function useResetPassword() {
});
}
export function useEvents() {
const { activeHouseholdId } = useHouseholdContext();
const query = useQuery({
queryKey: ['events'],
queryFn: () => api.get('/events'),
select: (d) => d.events,
enabled: !!activeHouseholdId,
});
return { ...query, isLoading: !activeHouseholdId || query.isLoading };
}
export function useCreateEvent() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload) => api.post('/events', payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ['events'] }),
});
}
export function useUpdateEvent() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...payload }) => api.put(`/events/${id}`, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ['events'] }),
});
}
export function useDeleteEvent() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id) => api.delete(`/events/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['events'] }),
});
}
export function useShoppingLists() {
const { activeHouseholdId } = useHouseholdContext();
const query = useQuery({
queryKey: ['shoppingLists'],
queryFn: () => api.get('/shopping-lists'),
select: (d) => d.lists,
enabled: !!activeHouseholdId,
});
return { ...query, isLoading: !activeHouseholdId || query.isLoading };
}
export function useShoppingList(id) {
const query = useQuery({
queryKey: ['shoppingList', id],
queryFn: () => api.get(`/shopping-lists/${id}`),
select: (d) => d.list,
enabled: !!id,
});
return query;
}
export function useCreateShoppingList() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload) => api.post('/shopping-lists', payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ['shoppingLists'] }),
});
}
export function useUpdateShoppingList() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...payload }) => api.put(`/shopping-lists/${id}`, payload),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: ['shoppingLists'] });
qc.invalidateQueries({ queryKey: ['shoppingList', id] });
},
});
}
export function useDeleteShoppingList() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id) => api.delete(`/shopping-lists/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['shoppingLists'] }),
});
}
export function useCreateShoppingItem() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ listId, ...payload }) => api.post(`/shopping-lists/${listId}/items`, payload),
onSuccess: (_, { listId }) => {
qc.invalidateQueries({ queryKey: ['shoppingLists'] });
qc.invalidateQueries({ queryKey: ['shoppingList', listId] });
},
});
}
export function useUpdateShoppingItem() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ listId, itemId, ...payload }) => api.put(`/shopping-lists/${listId}/items/${itemId}`, payload),
onSuccess: (_, { listId }) => {
qc.invalidateQueries({ queryKey: ['shoppingLists'] });
qc.invalidateQueries({ queryKey: ['shoppingList', listId] });
},
});
}
export function useDeleteShoppingItem() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ listId, itemId }) => api.delete(`/shopping-lists/${listId}/items/${itemId}`),
onSuccess: (_, { listId }) => {
qc.invalidateQueries({ queryKey: ['shoppingLists'] });
qc.invalidateQueries({ queryKey: ['shoppingList', listId] });
},
});
}
export function useSettleUp() {
const qc = useQueryClient();
return useMutation({

View File

@@ -7,10 +7,12 @@ export default function BottomNav() {
const items = [
{ to: '/', label: t('bottomNav.home'), icon: 'home', end: true },
{ to: '/calendar', label: t('bottomNav.calendar'), icon: 'calendar_month' },
{ to: '/history', label: t('bottomNav.history'), icon: 'receipt_long' },
];
const rightItems = [
{ to: '/shopping', label: t('bottomNav.shopping'), icon: 'checklist' },
{ to: '/settlements', label: t('bottomNav.settlements'), icon: 'payments' },
{ to: '/stats', label: t('bottomNav.stats'), icon: 'bar_chart' },
];

View File

@@ -0,0 +1,273 @@
import { useState } from 'react';
import { useCreateEvent, useUpdateEvent, useDeleteEvent } from '../api/queries.js';
import { useConfirm } from './ConfirmDialogProvider.jsx';
import { useTranslation } from '../i18n/I18nContext.jsx';
import Switch from './Switch.jsx';
import Icon from './Icon.jsx';
function dateOnly(value) {
return value ? value.slice(0, 10) : '';
}
export default function EventFormModal({ event, members, currentUserId, defaultDate, onClose }) {
const createEvent = useCreateEvent();
const updateEvent = useUpdateEvent();
const deleteEvent = useDeleteEvent();
const confirmDialog = useConfirm();
const { t, tError } = useTranslation();
const isEditing = !!event;
const initialAllDay = event ? !!event.all_day : true;
const [title, setTitle] = useState(event?.title || '');
const [allDay, setAllDay] = useState(initialAllDay);
const [startDate, setStartDate] = useState(
initialAllDay ? dateOnly(event?.start_at) || defaultDate : defaultDate
);
const [endDate, setEndDate] = useState(initialAllDay ? dateOnly(event?.end_at) || defaultDate : defaultDate);
const [startDateTime, setStartDateTime] = useState(!initialAllDay ? event?.start_at || '' : '');
const [endDateTime, setEndDateTime] = useState(!initialAllDay ? event?.end_at || '' : '');
const [location, setLocation] = useState(event?.location || '');
const [notes, setNotes] = useState(event?.notes || '');
const [attendeeIds, setAttendeeIds] = useState(
event ? event.attendees.filter((a) => !a.isGuest).map((a) => a.userId) : currentUserId ? [currentUserId] : []
);
const [guestNames, setGuestNames] = useState(
event ? event.attendees.filter((a) => a.isGuest).map((a) => a.name) : []
);
const [guestInput, setGuestInput] = useState('');
const [error, setError] = useState('');
function handleAllDayToggle(checked) {
setAllDay(checked);
if (checked) {
setStartDate(dateOnly(startDateTime) || startDate);
setEndDate(dateOnly(endDateTime) || endDate);
} else {
setStartDateTime(startDate ? `${startDate}T09:00` : '');
setEndDateTime(endDate ? `${endDate}T10:00` : '');
}
}
function toggleAttendee(id) {
setAttendeeIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
function addGuest() {
const name = guestInput.trim();
if (!name || guestNames.includes(name)) return;
setGuestNames((prev) => [...prev, name]);
setGuestInput('');
}
function removeGuest(name) {
setGuestNames((prev) => prev.filter((g) => g !== name));
}
async function handleSave() {
setError('');
const startAt = allDay ? startDate : startDateTime;
const endAt = allDay ? endDate : endDateTime;
if (!title.trim()) {
setError(t('eventFormModal.titleRequired'));
return;
}
if (!startAt || !endAt) {
setError(t('eventFormModal.dateRequired'));
return;
}
if (endAt < startAt) {
setError(t('errors.event_end_before_start'));
return;
}
const payload = {
title: title.trim(),
notes: notes.trim() || null,
location: location.trim() || null,
allDay,
startAt,
endAt,
attendeeUserIds: attendeeIds,
guestNames,
};
try {
if (isEditing) {
await updateEvent.mutateAsync({ id: event.id, ...payload });
} else {
await createEvent.mutateAsync(payload);
}
onClose();
} catch (err) {
setError(tError(err));
}
}
async function handleDelete() {
const ok = await confirmDialog({
title: t('eventFormModal.deleteConfirmTitle'),
message: t('eventFormModal.deleteConfirmMessage'),
confirmLabel: t('common.delete'),
});
if (!ok) return;
await deleteEvent.mutateAsync(event.id);
onClose();
}
const saving = createEvent.isPending || updateEvent.isPending;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>{isEditing ? t('eventFormModal.editTitle') : t('eventFormModal.createTitle')}</h3>
<button className="icon-btn" onClick={onClose}><Icon name="close" /></button>
</div>
<div className="expense-form">
<div className="field">
<label>{t('eventFormModal.titleLabel')}</label>
<input
type="text"
placeholder={t('eventFormModal.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
</div>
<div className="switch-row">
<div className="switch-label">
<span>{t('eventFormModal.allDayLabel')}</span>
</div>
<Switch checked={allDay} onChange={handleAllDayToggle} />
</div>
{allDay ? (
<div className="split-slider-amounts">
<div className="field">
<label>{t('eventFormModal.startDateLabel')}</label>
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</div>
<div className="field">
<label>{t('eventFormModal.endDateLabel')}</label>
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
</div>
) : (
<div className="split-slider-amounts">
<div className="field">
<label>{t('eventFormModal.startDateLabel')}</label>
<input
type="datetime-local"
value={startDateTime}
onChange={(e) => setStartDateTime(e.target.value)}
/>
</div>
<div className="field">
<label>{t('eventFormModal.endDateLabel')}</label>
<input type="datetime-local" value={endDateTime} onChange={(e) => setEndDateTime(e.target.value)} />
</div>
</div>
)}
<div className="field">
<label>{t('eventFormModal.locationLabel')}</label>
<input
type="text"
placeholder={t('eventFormModal.locationPlaceholder')}
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<div className="field">
<label>{t('eventFormModal.notesLabel')}</label>
<textarea
rows={3}
placeholder={t('eventFormModal.notesPlaceholder')}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="field">
<label>{t('eventFormModal.attendeesLabel')}</label>
<div className="toggle-row">
<button
type="button"
className="toggle-btn"
onClick={() => setAttendeeIds(currentUserId ? [currentUserId] : [])}
>
{t('eventFormModal.onlyMe')}
</button>
<button type="button" className="toggle-btn" onClick={() => setAttendeeIds(members.map((m) => m.id))}>
{t('eventFormModal.everyone')}
</button>
</div>
<div className="toggle-row" style={{ marginTop: 8 }}>
{members.map((m) => (
<button
key={m.id}
type="button"
className={`toggle-btn ${attendeeIds.includes(m.id) ? 'selected' : ''}`}
onClick={() => toggleAttendee(m.id)}
>
<Icon name="account_circle" /> {m.name}
</button>
))}
</div>
</div>
<div className="field">
<label>{t('eventFormModal.guestsLabel')}</label>
<div className="guest-input-row">
<input
type="text"
placeholder={t('eventFormModal.guestPlaceholder')}
value={guestInput}
onChange={(e) => setGuestInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addGuest();
}
}}
/>
<button type="button" className="btn-secondary" onClick={addGuest}>
<Icon name="add" />
</button>
</div>
{guestNames.length > 0 && (
<div className="chip-list">
{guestNames.map((name) => (
<span key={name} className="guest-chip">
{name}
<button type="button" onClick={() => removeGuest(name)} aria-label={t('common.delete')}>
<Icon name="close" />
</button>
</span>
))}
</div>
)}
</div>
{error && <p className="form-error">{error}</p>}
<div className="modal-actions">
{isEditing && (
<button type="button" className="btn-danger" onClick={handleDelete} disabled={deleteEvent.isPending}>
{t('common.delete')}
</button>
)}
<button type="button" className="btn-primary" onClick={handleSave} disabled={saving}>
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import Icon from './Icon.jsx';
import { useTranslation } from '../i18n/I18nContext.jsx';
function dateOnly(value) {
return value ? value.slice(0, 10) : '';
}
function timeOnly(value) {
return value && value.length > 10 ? value.slice(11, 16) : null;
}
export default function EventListItem({ event, onClick }) {
const { t } = useTranslation();
const startTime = timeOnly(event.start_at);
const endTime = timeOnly(event.end_at);
const multiDay = dateOnly(event.start_at) !== dateOnly(event.end_at);
let timeLabel;
if (event.all_day) {
timeLabel = multiDay
? `${t('calendar.allDay')} · ${dateOnly(event.start_at)} ${dateOnly(event.end_at)}`
: t('calendar.allDay');
} else {
timeLabel = startTime && endTime ? `${startTime} ${endTime}` : startTime || '';
}
return (
<div className="expense-item event-item" onClick={onClick}>
<div className="cat-icon">
<Icon name={event.all_day ? 'event' : 'schedule'} />
</div>
<div className="details">
<div className="title">{event.title}</div>
<div className="meta">
<span>{timeLabel}</span>
{event.location && (
<>
<span>·</span>
<Icon name="location_on" />
<span>{event.location}</span>
</>
)}
</div>
{event.attendees.length > 0 && (
<div className="meta">
<Icon name="group" />
<span>{event.attendees.map((a) => a.name).join(', ')}</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { useState } from 'react';
import { useUpdateShoppingItem, useDeleteShoppingItem } from '../api/queries.js';
import { useTranslation } from '../i18n/I18nContext.jsx';
import Icon from './Icon.jsx';
export default function ShoppingListItemRow({ item, listId }) {
const updateItem = useUpdateShoppingItem();
const deleteItem = useDeleteShoppingItem();
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(item.name);
const [quantity, setQuantity] = useState(item.quantity || '');
function toggleChecked() {
updateItem.mutate({ listId, itemId: item.id, checked: !item.checked });
}
function saveEdit() {
setEditing(false);
if (!name.trim()) {
setName(item.name);
return;
}
updateItem.mutate({ listId, itemId: item.id, name: name.trim(), quantity: quantity.trim() || null });
}
return (
<div className={`shopping-item-row ${item.checked ? 'checked' : ''}`}>
<input type="checkbox" checked={!!item.checked} onChange={toggleChecked} />
{editing ? (
<div className="shopping-item-edit">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => e.key === 'Enter' && saveEdit()}
autoFocus
/>
<input
type="text"
className="shopping-item-qty-input"
placeholder={t('shoppingListDetail.quantityPlaceholder')}
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => e.key === 'Enter' && saveEdit()}
/>
</div>
) : (
<button type="button" className="shopping-item-name" onClick={() => setEditing(true)}>
<span>{item.name}</span>
{item.quantity && <span className="shopping-item-qty">{item.quantity}</span>}
</button>
)}
<button
type="button"
className="icon-btn"
onClick={() => deleteItem.mutate({ listId, itemId: item.id })}
aria-label={t('common.delete')}
>
<Icon name="delete" />
</button>
</div>
);
}

View File

@@ -5,7 +5,17 @@ import { useAuth } from '../auth/AuthContext.jsx';
const HouseholdContext = createContext(null);
const HOUSEHOLD_SCOPED_KEYS = ['expenses', 'categories', 'balance', 'summary', 'monthly', 'settlements'];
const HOUSEHOLD_SCOPED_KEYS = [
'expenses',
'categories',
'balance',
'summary',
'monthly',
'settlements',
'events',
'shoppingLists',
'shoppingList',
];
export function HouseholdProvider({ children }) {
const { isAuthenticated } = useAuth();

View File

@@ -1,6 +1,8 @@
{
"home": "Home",
"calendar": "Calendar",
"history": "History",
"shopping": "Shopping",
"settlements": "Settlements",
"stats": "Stats",
"addExpense": "Add expense"

View File

@@ -0,0 +1,8 @@
{
"pageTitle": "Calendar",
"prevMonth": "Previous month",
"nextMonth": "Next month",
"addEvent": "Add event",
"emptyState": "No events on this day",
"allDay": "All day"
}

View File

@@ -3,13 +3,17 @@
"already_a_member": "You're already a member of this household",
"already_settled": "You're already settled up",
"amount_must_be_positive": "The amount must be greater than zero",
"attendee_must_be_member": "An attendee must be a member of the household",
"both_must_be_members": "Both people must be members of the household",
"category_name_required": "A category name is required",
"category_not_found": "Category not found",
"current_password_incorrect": "Current password is incorrect",
"email_already_registered": "An account with this email already exists",
"email_required": "Email is required",
"event_end_before_start": "The end date can't be before the start date",
"event_not_found": "Event not found",
"expense_not_found": "Expense not found",
"guest_name_required": "A guest name is required",
"household_has_no_members": "This household has no members to split the expense between",
"household_not_found": "Household not found",
"household_not_selected": "No household selected",
@@ -19,6 +23,7 @@
"invite_code_invalid_or_expired": "This invite code is invalid or has expired",
"invite_code_required": "An invite code is required",
"missing_bearer_token": "Authentication required",
"missing_event_fields": "Title, start date and end date are required",
"missing_expense_fields": "Amount, title, date, payer and split type are required",
"missing_login_fields": "Email and password are required",
"missing_password_fields": "Current and new password are required",
@@ -35,6 +40,10 @@
"share_assigned_to_non_member": "A share was assigned to someone outside the household",
"shares_must_sum_to_amount": "The shares must add up to the expense amount",
"shares_required": "Shares are required for this split type",
"shopping_item_name_required": "An item name is required",
"shopping_item_not_found": "Item not found",
"shopping_list_name_required": "A list name is required",
"shopping_list_not_found": "Shopping list not found",
"unknown_split_type": "Unknown split type",
"user_not_found": "User not found"
}

View File

@@ -0,0 +1,22 @@
{
"createTitle": "New event",
"editTitle": "Edit event",
"titleLabel": "Title",
"titlePlaceholder": "e.g. Family dinner",
"allDayLabel": "All day",
"startDateLabel": "Start",
"endDateLabel": "End",
"locationLabel": "Location",
"locationPlaceholder": "e.g. Home, restaurant...",
"notesLabel": "Notes",
"notesPlaceholder": "Additional details...",
"attendeesLabel": "Who's attending",
"onlyMe": "Only me",
"everyone": "Everyone",
"guestsLabel": "External guests",
"guestPlaceholder": "Guest name",
"titleRequired": "An event title is required",
"dateRequired": "A start and end date are required",
"deleteConfirmTitle": "Delete this event?",
"deleteConfirmMessage": "This action can't be undone."
}

View File

@@ -0,0 +1,9 @@
{
"itemNamePlaceholder": "Item name",
"quantityPlaceholder": "Qty",
"emptyState": "This list is empty — add the first item",
"notFound": "List not found",
"deleteListAria": "Delete list",
"deleteConfirmTitle": "Delete list \"{{name}}\"?",
"deleteConfirmMessage": "All items on this list will be deleted."
}

View File

@@ -0,0 +1,8 @@
{
"pageTitle": "Shopping lists",
"newListPlaceholder": "New list name",
"emptyState": "No shopping lists yet — add one above",
"progress": "{{checked}}/{{total}} checked",
"deleteConfirmTitle": "Delete list \"{{name}}\"?",
"deleteConfirmMessage": "All items on this list will be deleted."
}

View File

@@ -1,6 +1,8 @@
{
"home": "Start",
"calendar": "Kalendarz",
"history": "Historia",
"shopping": "Zakupy",
"settlements": "Rozliczenia",
"stats": "Statystyki",
"addExpense": "Dodaj wydatek"

View File

@@ -0,0 +1,8 @@
{
"pageTitle": "Kalendarz",
"prevMonth": "Poprzedni miesiąc",
"nextMonth": "Następny miesiąc",
"addEvent": "Dodaj wydarzenie",
"emptyState": "Brak wydarzeń tego dnia",
"allDay": "Cały dzień"
}

View File

@@ -3,13 +3,17 @@
"already_a_member": "Jesteś już członkiem tego gospodarstwa",
"already_settled": "Jesteście już rozliczeni",
"amount_must_be_positive": "Kwota musi być większa od zera",
"attendee_must_be_member": "Uczestnik musi być członkiem gospodarstwa domowego",
"both_must_be_members": "Obie osoby muszą być członkami gospodarstwa",
"category_name_required": "Nazwa kategorii jest wymagana",
"category_not_found": "Kategoria nie znaleziona",
"current_password_incorrect": "Bieżące hasło jest nieprawidłowe",
"email_already_registered": "Konto z tym adresem e-mail już istnieje",
"email_required": "E-mail jest wymagany",
"event_end_before_start": "Data końcowa nie może być wcześniejsza niż początkowa",
"event_not_found": "Wydarzenie nie znalezione",
"expense_not_found": "Wydatek nie znaleziony",
"guest_name_required": "Nazwa gościa jest wymagana",
"household_has_no_members": "Gospodarstwo domowe nie ma członków do podziału wydatku",
"household_not_found": "Gospodarstwo nie znalezione",
"household_not_selected": "Nie wybrano gospodarstwa",
@@ -19,6 +23,7 @@
"invite_code_invalid_or_expired": "Kod zaproszenia jest nieprawidłowy lub wygasł",
"invite_code_required": "Kod zaproszenia jest wymagany",
"missing_bearer_token": "Wymagane jest zalogowanie",
"missing_event_fields": "Nazwa, data początku i końca są wymagane",
"missing_expense_fields": "Kwota, tytuł, data, płacący i typ podziału są wymagane",
"missing_login_fields": "E-mail i hasło są wymagane",
"missing_password_fields": "Bieżące i nowe hasło są wymagane",
@@ -35,6 +40,10 @@
"share_assigned_to_non_member": "Udział przypisano osobie spoza gospodarstwa domowego",
"shares_must_sum_to_amount": "Suma udziałów musi być równa kwocie wydatku",
"shares_required": "Udziały są wymagane dla wybranego typu podziału",
"shopping_item_name_required": "Nazwa produktu jest wymagana",
"shopping_item_not_found": "Produkt nie znaleziony",
"shopping_list_name_required": "Nazwa listy jest wymagana",
"shopping_list_not_found": "Lista zakupów nie znaleziona",
"unknown_split_type": "Nieznany typ podziału",
"user_not_found": "Użytkownik nie znaleziony"
}

View File

@@ -0,0 +1,22 @@
{
"createTitle": "Nowe wydarzenie",
"editTitle": "Edytuj wydarzenie",
"titleLabel": "Nazwa",
"titlePlaceholder": "np. Kolacja rodzinna",
"allDayLabel": "Cały dzień",
"startDateLabel": "Początek",
"endDateLabel": "Koniec",
"locationLabel": "Lokalizacja",
"locationPlaceholder": "np. Dom, restauracja...",
"notesLabel": "Notatki",
"notesPlaceholder": "Dodatkowe informacje...",
"attendeesLabel": "Kto uczestniczy",
"onlyMe": "Tylko ja",
"everyone": "Wszyscy",
"guestsLabel": "Goście zewnętrzni",
"guestPlaceholder": "Imię gościa",
"titleRequired": "Nazwa wydarzenia jest wymagana",
"dateRequired": "Data początku i końca jest wymagana",
"deleteConfirmTitle": "Usunąć wydarzenie?",
"deleteConfirmMessage": "Tej operacji nie można cofnąć."
}

View File

@@ -0,0 +1,9 @@
{
"itemNamePlaceholder": "Nazwa produktu",
"quantityPlaceholder": "Ilość",
"emptyState": "Ta lista jest pusta — dodaj pierwszy produkt",
"notFound": "Lista nie znaleziona",
"deleteListAria": "Usuń listę",
"deleteConfirmTitle": "Usunąć listę „{{name}}”?",
"deleteConfirmMessage": "Wszystkie produkty na tej liście zostaną usunięte."
}

View File

@@ -0,0 +1,8 @@
{
"pageTitle": "Listy zakupów",
"newListPlaceholder": "Nazwa nowej listy",
"emptyState": "Brak list zakupów — dodaj pierwszą powyżej",
"progress": "{{checked}}/{{total}} zaznaczonych",
"deleteConfirmTitle": "Usunąć listę „{{name}}”?",
"deleteConfirmMessage": "Wszystkie produkty na tej liście zostaną usunięte."
}

View File

@@ -0,0 +1,166 @@
import { useMemo, useState } from 'react';
import { useEvents } from '../api/queries.js';
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
import { useAuth } from '../auth/AuthContext.jsx';
import { useTranslation } from '../i18n/I18nContext.jsx';
import EventListItem from '../components/EventListItem.jsx';
import EventFormModal from '../components/EventFormModal.jsx';
import Icon from '../components/Icon.jsx';
const WEEKDAY_LABELS = {
pl: ['Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'So', 'Nd'],
en: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
};
function toDateStr(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function todayStr() {
return toDateStr(new Date());
}
function buildMonthGrid(year, month) {
const firstOfMonth = new Date(year, month, 1);
const startOffset = (firstOfMonth.getDay() + 6) % 7; // Monday-first
const gridStart = new Date(year, month, 1 - startOffset);
const days = [];
for (let i = 0; i < 42; i++) {
const d = new Date(gridStart);
d.setDate(gridStart.getDate() + i);
days.push(d);
}
return days;
}
function eventOccursOn(event, dateStr) {
return dateStr >= event.start_at.slice(0, 10) && dateStr <= event.end_at.slice(0, 10);
}
export default function Calendar() {
const { user } = useAuth();
const { activeHousehold: household } = useHouseholdContext();
const { data: events, isLoading } = useEvents();
const { t, language } = useTranslation();
const [viewDate, setViewDate] = useState(() => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
});
const [selectedDate, setSelectedDate] = useState(todayStr());
const [editingEvent, setEditingEvent] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const members = household?.members || [];
const monthGrid = useMemo(() => buildMonthGrid(viewDate.getFullYear(), viewDate.getMonth()), [viewDate]);
const monthLabel = useMemo(
() =>
new Intl.DateTimeFormat(language === 'pl' ? 'pl-PL' : 'en-US', { month: 'long', year: 'numeric' }).format(
viewDate
),
[viewDate, language]
);
const weekdayLabels = WEEKDAY_LABELS[language] || WEEKDAY_LABELS.en;
const eventsByDay = useMemo(() => {
const map = {};
for (const day of monthGrid) {
const dateStr = toDateStr(day);
map[dateStr] = (events || []).filter((e) => eventOccursOn(e, dateStr));
}
return map;
}, [monthGrid, events]);
const selectedEvents = (eventsByDay[selectedDate] || []).slice().sort((a, b) => (a.start_at < b.start_at ? -1 : 1));
function changeMonth(delta) {
setViewDate((d) => new Date(d.getFullYear(), d.getMonth() + delta, 1));
}
function goToday() {
const now = new Date();
setViewDate(new Date(now.getFullYear(), now.getMonth(), 1));
setSelectedDate(todayStr());
}
return (
<div>
<h1 className="page-title">{t('calendar.pageTitle')}</h1>
<div className="calendar-header">
<button className="icon-btn" onClick={() => changeMonth(-1)} aria-label={t('calendar.prevMonth')}>
<Icon name="chevron_left" />
</button>
<button className="calendar-month-label" onClick={goToday}>{monthLabel}</button>
<button className="icon-btn" onClick={() => changeMonth(1)} aria-label={t('calendar.nextMonth')}>
<Icon name="chevron_right" />
</button>
</div>
<div className="calendar-grid calendar-weekdays">
{weekdayLabels.map((w) => (
<div key={w} className="calendar-weekday">{w}</div>
))}
</div>
<div className="calendar-grid">
{monthGrid.map((day) => {
const dateStr = toDateStr(day);
const isCurrentMonth = day.getMonth() === viewDate.getMonth();
const isToday = dateStr === todayStr();
const isSelected = dateStr === selectedDate;
const hasEvents = (eventsByDay[dateStr] || []).length > 0;
return (
<button
key={dateStr}
className={[
'calendar-day',
!isCurrentMonth && 'outside',
isToday && 'today',
isSelected && 'selected',
].filter(Boolean).join(' ')}
onClick={() => setSelectedDate(dateStr)}
>
<span>{day.getDate()}</span>
{hasEvents && <span className="calendar-dot" />}
</button>
);
})}
</div>
<div className="calendar-agenda">
<div className="calendar-agenda-header">
<h3>{selectedDate}</h3>
<button className="btn-secondary" onClick={() => setShowCreate(true)}>
<Icon name="add" /> {t('calendar.addEvent')}
</button>
</div>
{isLoading && <p>{t('common.loading')}</p>}
{!isLoading && selectedEvents.length === 0 && <p className="empty-state">{t('calendar.emptyState')}</p>}
{selectedEvents.map((event) => (
<EventListItem key={event.id} event={event} onClick={() => setEditingEvent(event)} />
))}
</div>
{showCreate && (
<EventFormModal
members={members}
currentUserId={user?.id}
defaultDate={selectedDate}
onClose={() => setShowCreate(false)}
/>
)}
{editingEvent && (
<EventFormModal
event={editingEvent}
members={members}
currentUserId={user?.id}
defaultDate={selectedDate}
onClose={() => setEditingEvent(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
useShoppingList,
useUpdateShoppingList,
useDeleteShoppingList,
useCreateShoppingItem,
} from '../api/queries.js';
import { useConfirm } from '../components/ConfirmDialogProvider.jsx';
import { useTranslation } from '../i18n/I18nContext.jsx';
import ShoppingListItemRow from '../components/ShoppingListItemRow.jsx';
import Icon from '../components/Icon.jsx';
export default function ShoppingListDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: list, isLoading } = useShoppingList(id);
const updateList = useUpdateShoppingList();
const deleteList = useDeleteShoppingList();
const createItem = useCreateShoppingItem();
const confirmDialog = useConfirm();
const { t } = useTranslation();
const [itemName, setItemName] = useState('');
const [itemQuantity, setItemQuantity] = useState('');
async function handleAddItem(e) {
e.preventDefault();
if (!itemName.trim()) return;
await createItem.mutateAsync({ listId: id, name: itemName.trim(), quantity: itemQuantity.trim() || null });
setItemName('');
setItemQuantity('');
}
async function handleDeleteList() {
const ok = await confirmDialog({
title: t('shoppingListDetail.deleteConfirmTitle', { name: list.name }),
message: t('shoppingListDetail.deleteConfirmMessage'),
confirmLabel: t('common.delete'),
});
if (!ok) return;
await deleteList.mutateAsync(id);
navigate('/shopping', { replace: true });
}
if (isLoading) return <p>{t('common.loading')}</p>;
if (!list) return <p className="empty-state">{t('shoppingListDetail.notFound')}</p>;
return (
<div>
<div className="calendar-header">
<button className="icon-btn" onClick={() => navigate('/shopping')} aria-label={t('common.close')}>
<Icon name="arrow_back" />
</button>
<input
type="text"
className="shopping-list-name-input"
defaultValue={list.name}
onBlur={(e) => {
const value = e.target.value.trim();
if (value && value !== list.name) updateList.mutate({ id, name: value });
}}
/>
<button className="icon-btn" onClick={handleDeleteList} aria-label={t('shoppingListDetail.deleteListAria')}>
<Icon name="delete_forever" />
</button>
</div>
<form onSubmit={handleAddItem} className="shopping-add-item-form">
<input
type="text"
placeholder={t('shoppingListDetail.itemNamePlaceholder')}
value={itemName}
onChange={(e) => setItemName(e.target.value)}
/>
<input
type="text"
className="shopping-item-qty-input"
placeholder={t('shoppingListDetail.quantityPlaceholder')}
value={itemQuantity}
onChange={(e) => setItemQuantity(e.target.value)}
/>
<button type="submit" className="btn-primary" disabled={createItem.isPending || !itemName.trim()}>
<Icon name="add" />
</button>
</form>
{list.items.length === 0 && <p className="empty-state">{t('shoppingListDetail.emptyState')}</p>}
{list.items.map((item) => (
<ShoppingListItemRow key={item.id} item={item} listId={id} />
))}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useShoppingLists, useCreateShoppingList, useDeleteShoppingList } from '../api/queries.js';
import { useConfirm } from '../components/ConfirmDialogProvider.jsx';
import { useTranslation } from '../i18n/I18nContext.jsx';
import Icon from '../components/Icon.jsx';
export default function ShoppingLists() {
const navigate = useNavigate();
const { data: lists, isLoading } = useShoppingLists();
const createList = useCreateShoppingList();
const deleteList = useDeleteShoppingList();
const confirmDialog = useConfirm();
const { t } = useTranslation();
const [name, setName] = useState('');
async function handleCreate(e) {
e.preventDefault();
if (!name.trim()) return;
await createList.mutateAsync({ name: name.trim() });
setName('');
}
async function handleDelete(e, list) {
e.stopPropagation();
const ok = await confirmDialog({
title: t('shoppingLists.deleteConfirmTitle', { name: list.name }),
message: t('shoppingLists.deleteConfirmMessage'),
confirmLabel: t('common.delete'),
});
if (!ok) return;
deleteList.mutate(list.id);
}
return (
<div>
<h1 className="page-title">{t('shoppingLists.pageTitle')}</h1>
<form onSubmit={handleCreate} className="shopping-add-list-form">
<input
type="text"
placeholder={t('shoppingLists.newListPlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit" className="btn-primary" disabled={createList.isPending || !name.trim()}>
<Icon name="add" />
</button>
</form>
{isLoading && <p>{t('common.loading')}</p>}
{!isLoading && (lists || []).length === 0 && <p className="empty-state">{t('shoppingLists.emptyState')}</p>}
{(lists || []).map((list) => (
<div key={list.id} className="shopping-list-card" onClick={() => navigate(`/shopping/${list.id}`)}>
<div className="shopping-list-card-main">
<Icon name="checklist" />
<div>
<div className="title">{list.name}</div>
<div className="switch-desc">
{t('shoppingLists.progress', { checked: list.checkedCount, total: list.total })}
</div>
</div>
</div>
<button type="button" className="icon-btn" onClick={(e) => handleDelete(e, list)} aria-label={t('common.delete')}>
<Icon name="delete" />
</button>
</div>
))}
</div>
);
}

View File

@@ -85,12 +85,12 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
left: 0;
right: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(7, 1fr);
align-items: center;
justify-items: center;
background: var(--card);
border-top: 1px solid var(--border);
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
padding: 6px 4px calc(6px + env(safe-area-inset-bottom));
z-index: 20;
}
@@ -100,12 +100,13 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
align-items: center;
justify-content: center;
gap: 2px;
font-size: 0.7rem;
font-size: 0.62rem;
color: var(--muted);
text-decoration: none;
padding: 6px 4px;
padding: 6px 2px;
border-radius: 12px;
width: 100%;
text-align: center;
}
.nav-item.active {
@@ -113,7 +114,7 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
font-weight: 600;
}
.nav-icon { font-size: 1.4rem; }
.nav-icon { font-size: 1.25rem; }
.fab {
width: 56px;
@@ -648,3 +649,164 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
justify-content: center;
gap: 6px;
}
/* Calendar */
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.calendar-month-label {
background: none;
border: none;
font-size: 1.1rem;
font-weight: 700;
color: var(--text);
cursor: pointer;
text-transform: capitalize;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.calendar-weekdays { margin-bottom: 4px; }
.calendar-weekday {
text-align: center;
font-size: 0.75rem;
color: var(--muted);
font-weight: 600;
}
.calendar-day {
position: relative;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 12px;
background: var(--card);
color: var(--text);
font-size: 0.9rem;
cursor: pointer;
}
.calendar-day.outside { color: var(--muted); opacity: 0.5; }
.calendar-day.today { font-weight: 700; color: var(--primary); }
.calendar-day.selected { background: var(--primary); color: white; }
.calendar-dot {
position: absolute;
bottom: 6px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--primary);
}
.calendar-day.selected .calendar-dot { background: white; }
.calendar-agenda { margin-top: 20px; }
.calendar-agenda-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.calendar-agenda-header h3 { margin: 0; }
.event-item .cat-icon { background: var(--accent-bg); color: var(--accent-text); }
/* Event form: attendees & guests */
.guest-input-row { display: flex; gap: 8px; }
.guest-input-row input { flex: 1; }
.chip-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.guest-chip {
display: flex;
align-items: center;
gap: 6px;
background: var(--accent-bg);
color: var(--accent-text);
border-radius: 999px;
padding: 6px 10px;
font-size: 0.85rem;
}
.guest-chip button {
background: none;
border: none;
color: inherit;
cursor: pointer;
display: flex;
padding: 0;
}
.guest-chip .material-symbols-outlined { font-size: 1rem; }
/* Shopping lists */
.shopping-add-list-form, .shopping-add-item-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.shopping-add-list-form input, .shopping-add-item-form input {
flex: 1;
box-sizing: border-box;
padding: 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
}
.shopping-add-list-form button, .shopping-add-item-form button { flex-shrink: 0; }
.shopping-list-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
background: var(--card);
border-radius: 12px;
margin-bottom: 8px;
cursor: pointer;
}
.shopping-list-card-main { display: flex; align-items: center; gap: 12px; }
.shopping-list-name-input {
flex: 1;
font-size: 1.1rem;
font-weight: 700;
border: none;
background: none;
color: var(--text);
padding: 8px;
min-width: 0;
}
.shopping-item-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 4px;
border-bottom: 1px solid var(--border);
}
.shopping-item-row:last-child { border-bottom: none; }
.shopping-item-row input[type='checkbox'] { width: 20px; height: 20px; flex-shrink: 0; }
.shopping-item-name {
flex: 1;
display: flex;
align-items: baseline;
gap: 8px;
background: none;
border: none;
text-align: left;
color: var(--text);
font: inherit;
padding: 4px;
min-width: 0;
}
.shopping-item-qty { font-size: 0.8rem; color: var(--muted); }
.shopping-item-row.checked .shopping-item-name { color: var(--muted); text-decoration: line-through; }
.shopping-item-edit { flex: 1; display: flex; gap: 8px; }
.shopping-item-edit input {
box-sizing: border-box;
padding: 8px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
}
.shopping-item-edit input:first-child { flex: 1; }
.shopping-item-qty-input { width: 90px; flex-shrink: 0; }