feat: custom day count for the week view
Some checks are pending
Playwright Tests / test (push) Waiting to run

Add a "Custom" option next to the 1/2/3/5/7 presets where any number of
days from 1 to 31 can be typed. The seven duplicated preset button rows
are replaced by a shared DaysSelector component with panel/header/menu
variants.

The day grid and all-day grid only had CSS for cols-1/2/3/5/7, so any
other count silently fell back to five columns; both now derive
grid-template-columns from the actual day count.

v1.115.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-03 10:14:50 +02:00
parent 2028ba80e0
commit 224fdba5c6
4 changed files with 215 additions and 29 deletions

View File

@ -2,6 +2,19 @@
All notable changes to this project are documented in this file.
## [1.115.0] - 2026-09-03
### Added
- Day columns: a "Custom" option next to the 1/2/3/5/7 presets, where any number of days from
1 to 31 can be typed (e.g. 14, 21, 28). The chosen value shows up as its own chip beside the
presets and is available in every place the day count can be set — sidebar, both settings
flyouts, the desktop toolbar and the header overflow menu.
### Fixed
- Day grid and all-day grid only had CSS for `cols-1/2/3/5/7`, so any other day count silently
fell back to a 5-column layout. Both grids now derive `grid-template-columns` from the actual
day count.
## [1.114.2] - 2026-09-01
### Fixed

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.114.2",
"version": "1.115.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {

View File

@ -0,0 +1,183 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
export const DAY_PRESETS = [1, 2, 3, 5, 7];
export const MAX_VIEW_DAYS = 31;
export function clampViewDays(n: number): number {
if (!Number.isFinite(n)) return 7;
return Math.min(MAX_VIEW_DAYS, Math.max(1, Math.round(n)));
}
type Variant = "panel" | "header" | "menu";
interface DaysSelectorProps {
viewDays: number;
onChange: (days: number) => void;
darkMode?: boolean;
language?: string;
variant?: Variant;
}
/**
* Day-column count picker: the fixed presets plus a "Custom" field where the
* user types any number of days (1–31), e.g. 14, 21, 28.
*/
export default function DaysSelector({
viewDays,
onChange,
darkMode = false,
language = "en",
variant = "panel",
}: DaysSelectorProps) {
const isCustom = !DAY_PRESETS.includes(viewDays);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(String(viewDays));
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
setDraft(String(viewDays));
inputRef.current?.focus();
inputRef.current?.select();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing]);
const commit = () => {
const parsed = parseInt(draft, 10);
if (!isNaN(parsed)) onChange(clampViewDays(parsed));
setEditing(false);
};
const isDE = language === "de";
const customLabel = isDE ? "Eigene Anzahl Tage" : "Custom number of days";
const buttons = isCustom ? [...DAY_PRESETS, viewDays] : DAY_PRESETS;
if (variant === "header") {
const cls = (active: boolean) =>
`px-2 py-0.5 text-xs rounded transition-colors ${active ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`;
return (
<>
{buttons.map((num) => (
<button key={num} onClick={() => onChange(num)} className={cls(viewDays === num)}>
{num}
</button>
))}
{editing ? (
<input
ref={inputRef}
type="number"
min={1}
max={MAX_VIEW_DAYS}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }}
className="w-12 px-1 py-0.5 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-black dark:text-white"
/>
) : (
<button onClick={() => setEditing(true)} className={cls(false)} title={customLabel}>…</button>
)}
</>
);
}
if (variant === "menu") {
const btnStyle = (active: boolean): React.CSSProperties => ({
padding: "2px 8px",
fontSize: "12px",
borderRadius: "4px",
border: "none",
cursor: "pointer",
fontWeight: active ? 700 : 400,
background: active ? "var(--bg-secondary, #e5e7eb)" : "transparent",
color: "inherit",
});
return (
<>
{buttons.map((num) => (
<button key={num} onClick={() => onChange(num)} style={btnStyle(viewDays === num)}>
{num}
</button>
))}
{editing ? (
<input
ref={inputRef}
type="number"
min={1}
max={MAX_VIEW_DAYS}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }}
style={{ width: "48px", padding: "2px 4px", fontSize: "12px", borderRadius: "4px", border: "1px solid var(--line, #d1d5db)", background: "transparent", color: "inherit" }}
/>
) : (
<button onClick={() => setEditing(true)} style={btnStyle(false)} title={customLabel}>…</button>
)}
</>
);
}
// "panel" — sidebar / flyout chip row
const chipStyle = (active: boolean): React.CSSProperties => ({
flex: 1,
padding: "5px 0",
fontSize: "0.8rem",
borderRadius: "6px",
border: "none",
cursor: "pointer",
fontWeight: active ? 700 : 400,
background: active ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"),
color: active ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)"),
});
return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<div style={{ display: "flex", gap: "4px" }}>
{buttons.map((num) => (
<button key={num} onClick={() => onChange(num)} style={chipStyle(viewDays === num)}>
{num}
</button>
))}
<button
onClick={() => setEditing((v) => !v)}
style={chipStyle(false)}
title={customLabel}
>
{isDE ? "Eigene" : "Custom"}
</button>
</div>
{editing && (
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
<input
ref={inputRef}
type="number"
min={1}
max={MAX_VIEW_DAYS}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }}
placeholder={`1-${MAX_VIEW_DAYS}`}
style={{
flex: 1,
minWidth: 0,
padding: "5px 6px",
fontSize: "0.8rem",
borderRadius: "6px",
border: `1px solid ${darkMode ? "#374151" : "var(--line, #d1d5db)"}`,
background: darkMode ? "#111827" : "#fff",
color: darkMode ? "#e5e7eb" : "var(--ink)",
}}
/>
<button onClick={commit} style={{ ...chipStyle(true), flex: "0 0 auto", padding: "5px 10px" }}>
OK
</button>
</div>
)}
</div>
);
}

View File

@ -31,6 +31,7 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import FocusModeOverlay from "./FocusModeOverlay";
import DaysSelector, { clampViewDays } from "./DaysSelector";
import {
LayoutGrid,
LayoutPanelLeft,
@ -2455,6 +2456,13 @@ export default function WeeklyView() {
}
};
const handleViewDaysChange = (num: number) => {
const days = clampViewDays(num);
setViewDays(days);
savedViewDaysRef.current = days;
saveSetting("viewDays", days);
};
const handleSettingsChanged = (newSettings: any) => {
setShowTimeGrid(newSettings.showTimeGrid);
setCellDuration(newSettings.cellDuration);
@ -5645,7 +5653,7 @@ export default function WeeklyView() {
{isAllDayExpanded && (
<div
className={`all-day-events-grid cols-${viewDays}`}
style={{ flex: 1 }}
style={{ flex: 1, gridTemplateColumns: `repeat(${viewDays}, minmax(0, 1fr))` }}
>
{getVisibleDays().map((date) => {
const dayEvents = getAllDayEventsForDate(date);
@ -5788,7 +5796,7 @@ export default function WeeklyView() {
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Spalten" : "Days"}</span>
<div style={{ display: "flex", gap: "4px" }}>{[1, 2, 3, 5, 7].map((num) => (<button key={num} onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{num}</button>))}</div>
<DaysSelector viewDays={viewDays} onChange={handleViewDaysChange} darkMode={darkMode} language={profile.language} />
</div>
{profile.showTimeGrid && (<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Zeitfenster" : "Slot"}</span><div style={{ display: "flex", gap: "4px" }}>{(([15, 20, 30, 60] as CellDuration[])).map((d) => (<button key={d} onClick={() => { setCellDuration(d); saveSetting("cellDuration", d); saveViewSetting("cellDuration", d, true); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === d ? 700 : 400, background: effectiveCellDuration === d ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: effectiveCellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{d}m</button>))}</div></div>)}
{profile.showTimeGrid && (<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>:15/:30/:45</span><button onClick={() => { saveViewSetting("showSubHourSlots", !effectiveShowSubHourSlots, true); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>{effectiveShowSubHourSlots ? <Eye size={16} /> : <EyeOff size={16} />}</button></div>)}
@ -5847,7 +5855,7 @@ export default function WeeklyView() {
</div>
<div style={{ borderTop: "1px solid var(--line)", paddingTop: "8px" }} />
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Ansicht" : "View"}</span><div style={{ display: "flex", gap: "4px" }}>{[{ key: "simple", icon: <Calendar size={13} /> }, { key: "calendar", icon: <CalendarDays size={13} /> }, { key: "list", icon: <ListTodo size={13} /> }, { key: "kanban", icon: <Kanban size={13} /> }, { key: "priority", icon: <Target size={13} /> }].map((v) => (<button key={v.key} onClick={() => { setViewStyle(v.key as any); saveSetting("viewStyle", v.key); if (v.key === "list") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } if (v.key === "simple" || v.key === "calendar") { setShowTimeGrid(true); saveSetting("showTimeGrid", true); } if (v.key === "priority" || v.key === "kanban") { setShowTimeGrid(false); saveSetting("showTimeGrid", false); } }} style={{ flex: 1, padding: "5px 0", borderRadius: "6px", border: "none", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: profile.viewStyle === v.key ? 700 : 400, background: profile.viewStyle === v.key ? "var(--accent)" : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.viewStyle === v.key ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{v.icon}</button>))}</div></div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Spalten" : "Days"}</span><div style={{ display: "flex", gap: "4px" }}>{[1, 2, 3, 5, 7].map((num) => (<button key={num} onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{num}</button>))}</div></div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Spalten" : "Days"}</span><DaysSelector viewDays={viewDays} onChange={handleViewDaysChange} darkMode={darkMode} language={profile.language} /></div>
{profile.showTimeGrid && (<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Zeitfenster" : "Slot"}</span><div style={{ display: "flex", gap: "4px" }}>{(([15, 20, 30, 60] as CellDuration[])).map((d) => (<button key={d} onClick={() => { setCellDuration(d); saveSetting("cellDuration", d); saveViewSetting("cellDuration", d, true); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === d ? 700 : 400, background: effectiveCellDuration === d ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: effectiveCellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{d}m</button>))}</div></div>)}
{profile.showTimeGrid && (<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><span style={{ fontSize: "0.75rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>:15/:30/:45</span><button onClick={() => { saveViewSetting("showSubHourSlots", !effectiveShowSubHourSlots, true); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: darkMode ? "#9ca3af" : "#6b7280" }}>{effectiveShowSubHourSlots ? <Eye size={16} /> : <EyeOff size={16} />}</button></div>)}
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "#6b7280" }}>{profile.language === "de" ? "Textgröße" : "Text size"}</span><div style={{ display: "flex", gap: "4px" }}>{(["S", "M", "L"] as const).map((size) => (<button key={size} onClick={() => { setFontSize(size); saveSetting("fontSize", size); }} style={{ padding: "4px 10px", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: profile.fontSize === size ? 700 : 400, background: profile.fontSize === size ? (darkMode ? "#374151" : "#333") : (darkMode ? "#1f2937" : "#e5e7eb"), color: profile.fontSize === size ? "#fff" : (darkMode ? "#9ca3af" : "#6b7280") }}>{size}</button>))}</div></div>
@ -5919,7 +5927,7 @@ export default function WeeklyView() {
{/* Columns / Days */}
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Spalten" : "Days"}</span>
<div style={{ display: "flex", gap: "4px" }}>{[1, 2, 3, 5, 7].map((num) => (<button key={num} onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{num}</button>))}</div>
<DaysSelector viewDays={viewDays} onChange={handleViewDaysChange} darkMode={darkMode} language={profile.language} />
</div>
{/* Slot Duration */}
{profile.showTimeGrid && (<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}><span style={{ fontSize: "0.7rem", fontWeight: 600, color: darkMode ? "#9ca3af" : "var(--ink-3)" }}>{profile.language === "de" ? "Zeitfenster" : "Slot"}</span><div style={{ display: "flex", gap: "4px" }}>{(([15, 20, 30, 60] as CellDuration[])).map((d) => (<button key={d} onClick={() => { setCellDuration(d); saveSetting("cellDuration", d); saveViewSetting("cellDuration", d, true); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: effectiveCellDuration === d ? 700 : 400, background: effectiveCellDuration === d ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: effectiveCellDuration === d ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{d}m</button>))}</div></div>)}
@ -6046,7 +6054,7 @@ export default function WeeklyView() {
{flyoutSection === "columns" && (
<>
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Spalten" : "Days"}</div>
<div style={{ display: "flex", gap: "4px" }}>{[1, 2, 3, 5, 7].map((num) => (<button key={num} onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{num}</button>))}</div>
<DaysSelector viewDays={viewDays} onChange={handleViewDaysChange} darkMode={darkMode} language={profile.language} />
</>
)}
{flyoutSection === "slot" && (
@ -6259,7 +6267,7 @@ export default function WeeklyView() {
{flyoutSection === "columns" && (
<>
<div style={flyoutLabelStyle}>{profile.language === "de" ? "Spalten" : "Days"}</div>
<div style={{ display: "flex", gap: "4px" }}>{[1, 2, 3, 5, 7].map((num) => (<button key={num} onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }} style={{ flex: 1, padding: "5px 0", fontSize: "0.8rem", borderRadius: "6px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? (darkMode ? "#374151" : "var(--ink)") : (darkMode ? "#1f2937" : "#e5e7eb"), color: viewDays === num ? "#fff" : (darkMode ? "#9ca3af" : "var(--ink-3)") }}>{num}</button>))}</div>
<DaysSelector viewDays={viewDays} onChange={handleViewDaysChange} darkMode={darkMode} language={profile.language} />
</>
)}
{flyoutSection === "slot" && (
@ -6344,19 +6352,7 @@ export default function WeeklyView() {
title="Days to show"
>
<LayoutGrid size={16} className="text-gray-500 mr-1" />
{[1, 2, 3, 5, 7].map((num) => (
<button
key={num}
onClick={() => {
setViewDays(num);
savedViewDaysRef.current = num;
saveSetting("viewDays", num);
}}
className={`px-2 py-0.5 text-xs rounded transition-colors ${viewDays === num ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
>
{num}
</button>
))}
<DaysSelector viewDays={viewDays} onChange={handleViewDaysChange} darkMode={darkMode} language={profile.language} variant="header" />
</div>
{/* Slot Duration — desktop only (available in sidebar on tablet) */}
@ -6634,15 +6630,7 @@ export default function WeeklyView() {
<div style={{ borderTop: "1px solid var(--border-color, #e5e7eb)", margin: "2px 0" }} />
<div style={{ padding: "6px 12px", display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" }}>
<span style={{ fontSize: "12px", color: "#888", marginRight: "4px" }}>{profile.language === "de" ? "Tage" : "Days"}:</span>
{[1, 2, 3, 5, 7].map((num) => (
<button
key={num}
onClick={() => { setViewDays(num); savedViewDaysRef.current = num; saveSetting("viewDays", num); }}
style={{ padding: "2px 8px", fontSize: "12px", borderRadius: "4px", border: "none", cursor: "pointer", fontWeight: viewDays === num ? 700 : 400, background: viewDays === num ? "var(--bg-secondary, #e5e7eb)" : "transparent", color: "inherit" }}
>
{num}
</button>
))}
<DaysSelector viewDays={viewDays} onChange={handleViewDaysChange} darkMode={darkMode} language={profile.language} variant="menu" />
</div>
{profile.showTimeGrid && (
<div style={{ padding: "6px 12px", display: "flex", alignItems: "center", gap: "6px" }}>
@ -7404,10 +7392,12 @@ export default function WeeklyView() {
data-slide-direction={slideDirection}
data-nav-type={viewDays > 1 ? "week" : "day"}
style={profile.showTimeGrid ? {
gridTemplateColumns: `repeat(${viewDays}, minmax(0, 1fr))`,
height: `${24 * (60 / effectiveCellDuration) * getSlotHeight(effectiveCellDuration) + getHeaderHeight(effectiveCellDuration)}px`,
flex: 1,
alignSelf: "flex-start",
} : {
gridTemplateColumns: `repeat(${viewDays}, minmax(0, 1fr))`,
flex: 1,
}}
>