feat(dailies): edit the strip in place, and give weekly and monthly tasks their own row
Giving Genshin's "To Temper Thyself and Journey Far" its five-of-seven goal meant finding it in the list, opening its sheet, saying it repeats and only then setting the goal. The strip now has an Edit mode beside Catch up that lists its events by game with their goals, opens the sheet's goal control in place, and adds any running event a game at a time. It calls the same setDaily/setGoal the sheet does, so it can store nothing the sheet would not. Weekly and monthly tasks are not due tonight, so they move to a row under the dailies, and each period can be switched off (showWeeklyTasks, showMonthlyTasks; default on, so nothing disappears on upgrade). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c928ee795
commit
9477ac54b6
13 files changed
+855
-48
No files matched your search
@@ -96,6 +96,13 @@ export function Controls({
|
||||
const shown = games.filter((g) => !prefs.hiddenGames.includes(g)).length;
|
||||
const ownGames = Object.keys(own.games).length;
|
||||
const ownEvents = Object.keys(own.events).length;
|
||||
// Which periods have a task at all — the reader's own, or a built-in they
|
||||
// turned on — so a switch is offered only with something behind it.
|
||||
const periods = [
|
||||
...(own.tasks ?? []).map((t) => t.period.unit),
|
||||
...(prefs.routineGames ?? []).flatMap((g) => routinesFor(g).map((r) => r.period.unit)),
|
||||
];
|
||||
const taskPeriods = { week: periods.includes("week"), month: periods.includes("month") };
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -248,6 +255,26 @@ export function Controls({
|
||||
hint="Commissions, resin, daily training and the rest. Nobody publishes these, so the app keeps a fixed list per game. Off, the strip shows only events with a checklist. Your ticks and streaks are kept either way."
|
||||
/>
|
||||
|
||||
{/* Where weekly and monthly tasks sit: their own row under the
|
||||
dailies. Only offered once there is a task of that period — a
|
||||
switch with nothing behind it is a control that does nothing. */}
|
||||
{taskPeriods.week && (
|
||||
<Check
|
||||
checked={prefs.showWeeklyTasks}
|
||||
onChange={(showWeeklyTasks) => onUpdate({ showWeeklyTasks })}
|
||||
label="Show weekly tasks under the dailies"
|
||||
hint="Your own and any built-ins you turned on. Off hides them from the strip and its counts; your ticks and numbers are kept."
|
||||
/>
|
||||
)}
|
||||
{taskPeriods.month && (
|
||||
<Check
|
||||
checked={prefs.showMonthlyTasks}
|
||||
onChange={(showMonthlyTasks) => onUpdate({ showMonthlyTasks })}
|
||||
label="Show monthly tasks under the dailies"
|
||||
hint="Off hides them from the strip and its counts; your ticks and numbers are kept."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Beside the chores rather than on each game's row, which already
|
||||
carries a handle, two arrows and a switch. Opt-in per game: these
|
||||
are someone else's figures, each dated by its source. */}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { asOfLabel } from "../../shared/routines.ts";
|
||||
import { AmountEditor } from "./AmountEditor.tsx";
|
||||
import { formatRemaining } from "../../shared/time.ts";
|
||||
import { DayPip } from "./DayPip.tsx";
|
||||
import { EditPanel, type StripEditor } from "./EditPanel.tsx";
|
||||
import { Fireworks } from "./Fireworks.tsx";
|
||||
|
||||
/**
|
||||
@@ -207,6 +208,31 @@ export function dailyGroups(
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks the reader has not switched off, by period. Off hides a task from the
|
||||
* strip and its counts; its ticks and numbers keep their keys untouched.
|
||||
*/
|
||||
export function tasksShown<T extends Pick<StripTask, "period">>(
|
||||
tasks: readonly T[],
|
||||
shown: { weekly: boolean; monthly: boolean },
|
||||
): T[] {
|
||||
return tasks.filter((t) => (t.period.unit === "week" ? shown.weekly : shown.monthly));
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's row and the one under it. Weekly and monthly items are not due
|
||||
* tonight, so they sit below the dailies rather than among them; an event is
|
||||
* in today's row whatever its goal, because it is ticked a day at a time. Order
|
||||
* inside each row is kept, so a game's things stay together.
|
||||
*/
|
||||
export function splitByCadence(items: readonly DailyItem[]): {
|
||||
today: DailyItem[];
|
||||
longer: DailyItem[];
|
||||
} {
|
||||
const longer = (i: DailyItem) => i.cadence === "week" || i.cadence === "month";
|
||||
return { today: items.filter((i) => !longer(i)), longer: items.filter(longer) };
|
||||
}
|
||||
|
||||
interface PeriodTally {
|
||||
total: number;
|
||||
complete: number;
|
||||
@@ -278,6 +304,7 @@ export function Dailies({
|
||||
onSetAmount,
|
||||
amountFor,
|
||||
ask,
|
||||
edit,
|
||||
}: {
|
||||
/** Every lane the reader is looking at, in their own order. */
|
||||
games: LaneId[];
|
||||
@@ -314,6 +341,8 @@ export function Dailies({
|
||||
onSetAmount?: ((key: string, periodStart: string, value: number) => void) | undefined;
|
||||
/** The number recorded for an item's period, if any. */
|
||||
amountFor?: ((key: string, periodStart: string) => number | undefined) | undefined;
|
||||
/** What the Edit panel shows and does; without it there is no Edit. */
|
||||
edit?: StripEditor | undefined;
|
||||
}) {
|
||||
const gameMeta = useGameMeta();
|
||||
/**
|
||||
@@ -324,7 +353,9 @@ export function Dailies({
|
||||
* reading — "I did Tuesday, let me say so" — rather than a statement about how
|
||||
* the app should work.
|
||||
*/
|
||||
const [catchUp, setCatchUp] = useState(false);
|
||||
const [panel, setPanel] = useState<"catchUp" | "edit" | null>(null);
|
||||
const catchUp = panel === "catchUp";
|
||||
const editingStrip = panel === "edit" && edit !== undefined;
|
||||
/** Which amount chip's editor is open, by item key. One at a time. */
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
@@ -372,7 +403,23 @@ export function Dailies({
|
||||
return () => clearTimeout(id);
|
||||
}, [burst]);
|
||||
|
||||
if (items.length === 0 && ask === undefined) return null;
|
||||
// An empty strip used to vanish. With something running that could be added,
|
||||
// it stays as one line, so the way to add it is where the reader looks.
|
||||
const canAdd = edit !== undefined && edit.addable.length > 0;
|
||||
if (items.length === 0 && ask === undefined && !canAdd) return null;
|
||||
|
||||
/** The Edit switch. Beside Catch up, and like it a mode of the section, never a chip's. */
|
||||
const editToggle = edit !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanel(editingStrip ? null : "edit")}
|
||||
aria-expanded={editingStrip}
|
||||
className="shrink-0 text-[0.6875rem] text-faint transition-colors hover:text-muted"
|
||||
>
|
||||
{editingStrip ? "Done" : "Edit"}
|
||||
</button>
|
||||
);
|
||||
const editPanel = editingStrip && <EditPanel games={games} editor={edit} />;
|
||||
|
||||
const question =
|
||||
ask === undefined ? null : (
|
||||
@@ -402,7 +449,22 @@ export function Dailies({
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return <section className="relative border-b border-hairline px-4 py-3">{question}</section>;
|
||||
return (
|
||||
<section className="relative border-b border-hairline px-4 py-3">
|
||||
{question}
|
||||
{canAdd && (
|
||||
<>
|
||||
{editPanel}
|
||||
<div className={`relative flex items-baseline justify-between gap-3 ${question === null ? "" : "mt-2"}`}>
|
||||
<p className="text-[0.6875rem] text-faint">
|
||||
{editingStrip ? "Add an event to tick it off here each day." : "Nothing to tick today."}
|
||||
</p>
|
||||
{editToggle}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// With mixed reset clocks there is no single "resets in", so the header
|
||||
@@ -411,6 +473,34 @@ export function Dailies({
|
||||
const mixed = new Set(items.map((i) => i.resetsIn)).size > 1;
|
||||
const open = items.find((i) => i.key === editing && i.goal?.measure === "amount");
|
||||
|
||||
const { today, longer } = splitByCadence(items);
|
||||
const chip = (item: DailyItem) => (
|
||||
<li key={item.key}>
|
||||
{item.goal?.measure === "amount" ? (
|
||||
<AmountChip
|
||||
label={item.label}
|
||||
hue={gameMeta(item.game).hue}
|
||||
title={item.title}
|
||||
ariaLabel={item.ariaLabel}
|
||||
goal={item.goal}
|
||||
open={editing === item.key}
|
||||
onOpen={() => setEditing(editing === item.key ? null : item.key)}
|
||||
/>
|
||||
) : (
|
||||
<TickChip
|
||||
label={item.label}
|
||||
hue={gameMeta(item.game).hue}
|
||||
title={item.title}
|
||||
ariaLabel={item.ariaLabel}
|
||||
days={daysFor(item.key)}
|
||||
today={item.today}
|
||||
goal={item.goal}
|
||||
onToggle={() => onToggleDay(item.key, item.today)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="relative border-b border-hairline px-4 py-4">
|
||||
{burst > 0 && <Fireworks key={burst} />}
|
||||
@@ -445,45 +535,36 @@ export function Dailies({
|
||||
onSetAmount={onSetAmount}
|
||||
amountFor={amountFor}
|
||||
/>
|
||||
) : editingStrip ? (
|
||||
editPanel
|
||||
) : (
|
||||
/* One wrapping row, with each game's chore and its events adjacent. No
|
||||
/* Wrapping rows, with each game's chore and its events adjacent. No
|
||||
per-game headings here: this is the part of the page answerable in ten
|
||||
seconds, and a heading per game would make it the tallest block on it,
|
||||
pushing "next to expire" — the answer the reader came for — down the
|
||||
page. The hue already says which game a chip belongs to. */
|
||||
<ul className="relative mt-2.5 flex flex-wrap gap-1.5">
|
||||
{items.map((item) => (
|
||||
<li key={item.key}>
|
||||
{item.goal?.measure === "amount" ? (
|
||||
<AmountChip
|
||||
label={item.label}
|
||||
hue={gameMeta(item.game).hue}
|
||||
title={item.title}
|
||||
ariaLabel={item.ariaLabel}
|
||||
goal={item.goal}
|
||||
open={editing === item.key}
|
||||
onOpen={() => setEditing(editing === item.key ? null : item.key)}
|
||||
/>
|
||||
) : (
|
||||
<TickChip
|
||||
label={item.label}
|
||||
hue={gameMeta(item.game).hue}
|
||||
title={item.title}
|
||||
ariaLabel={item.ariaLabel}
|
||||
days={daysFor(item.key)}
|
||||
today={item.today}
|
||||
goal={item.goal}
|
||||
onToggle={() => onToggleDay(item.key, item.today)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<ul className="relative mt-2.5 flex flex-wrap gap-1.5">
|
||||
{today.map(chip)}
|
||||
</ul>
|
||||
{/* Not due tonight, so under the dailies rather than among them —
|
||||
and switchable, in the Edit panel and in settings. */}
|
||||
{longer.length > 0 && (
|
||||
<>
|
||||
<p className="relative mt-3 text-[0.6875rem] font-medium text-faint">
|
||||
This week & month
|
||||
</p>
|
||||
<ul className="relative mt-1.5 flex flex-wrap gap-1.5">
|
||||
{longer.map(chip)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Under the strip rather than inside the chip: a chip is one target, and
|
||||
a field inside it would be a second one waiting to be mis-tapped. */}
|
||||
{!catchUp && open?.goal !== undefined && onSetAmount !== undefined && (
|
||||
{panel === null && open?.goal !== undefined && onSetAmount !== undefined && (
|
||||
<AmountEditor
|
||||
key={`${open.key}:${open.goal.periodStart}`}
|
||||
autoFocus
|
||||
@@ -501,7 +582,9 @@ export function Dailies({
|
||||
|
||||
<div className="relative mt-2 flex items-baseline justify-between gap-3">
|
||||
<p className="text-[0.6875rem] leading-relaxed text-faint">
|
||||
{catchUp
|
||||
{editingStrip
|
||||
? "Add an event, give one a goal, or choose what the strip carries."
|
||||
: catchUp
|
||||
? `Tick a day you did but didn't record. The last ${CATCH_UP_DAYS} days, and nothing later than today.`
|
||||
: total === 0
|
||||
? week.complete + month.complete === week.total + month.total
|
||||
@@ -514,14 +597,17 @@ export function Dailies({
|
||||
{/* The way back to a day you already did. It lives on the section rather
|
||||
than on each chip because a chip is a single tick target, and a
|
||||
second control inside one is a mis-tap that costs a streak. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatchUp((on) => !on)}
|
||||
aria-expanded={catchUp}
|
||||
className="shrink-0 text-[0.6875rem] text-faint transition-colors hover:text-muted"
|
||||
>
|
||||
{catchUp ? "Done" : "Catch up"}
|
||||
</button>
|
||||
<div className="flex shrink-0 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanel(catchUp ? null : "catchUp")}
|
||||
aria-expanded={catchUp}
|
||||
className="shrink-0 text-[0.6875rem] text-faint transition-colors hover:text-muted"
|
||||
>
|
||||
{catchUp ? "Done" : "Catch up"}
|
||||
</button>
|
||||
{editToggle}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useState } from "react";
|
||||
import type { LaneId } from "../../shared/custom.ts";
|
||||
import { dailyOverride } from "../../shared/daily.ts";
|
||||
import { goalSummary, type Goal } from "../../shared/goal.ts";
|
||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||
import { GoalControl } from "./GoalControl.tsx";
|
||||
|
||||
/**
|
||||
* Editing Today's dailies from the strip.
|
||||
*
|
||||
* Giving Genshin's “To Temper Thyself and Journey Far” its five-of-seven goal
|
||||
* used to mean finding the event in the list, opening its sheet, saying it
|
||||
* repeats and only then setting the goal. This panel does the same things with
|
||||
* the same handlers the sheet uses — `progress.daily` and `progress.goal` —
|
||||
* so nothing here stores anything the sheet would not.
|
||||
*
|
||||
* A section-level mode, like Catch up, and never a control inside a chip: a
|
||||
* chip is one tick target, and a second one inside it is a mis-tap that costs
|
||||
* a streak.
|
||||
*/
|
||||
|
||||
/** An event as the panel needs it: what it is, its goal, and what bounds one. */
|
||||
export interface StripEvent {
|
||||
id: string;
|
||||
game: LaneId;
|
||||
title: string;
|
||||
/** The goal, when it is one this build can read. */
|
||||
goal: Goal | undefined;
|
||||
/** Something is stored under `goal`, readable or not. */
|
||||
hasStoredGoal: boolean;
|
||||
/** Detection is on and says this one repeats — what `dailyOverride` compares against. */
|
||||
detected: boolean;
|
||||
/** The weekday of the event's first game-day: its weeks most likely start there. */
|
||||
defaultWeekStartsOn: number;
|
||||
/** Claimable days in the run, or null when the end is unannounced. */
|
||||
maxDays: number | null;
|
||||
}
|
||||
|
||||
/** Which parts of the strip are switched on. */
|
||||
export interface StripShown {
|
||||
chores: boolean;
|
||||
weekly: boolean;
|
||||
monthly: boolean;
|
||||
}
|
||||
|
||||
/** Everything the strip needs to be edited in place. Built by App. */
|
||||
export interface StripEditor {
|
||||
/** Running events already in the strip. */
|
||||
inStrip: StripEvent[];
|
||||
/** Running events the reader has not finished or ignored that are not in it. */
|
||||
addable: StripEvent[];
|
||||
onGoal: (id: string, goal: Goal | undefined) => void;
|
||||
/** `useProgress().setDaily`: the override, and whether the goal goes with it. */
|
||||
onDaily: (id: string, daily: boolean | undefined, clearsGoal?: boolean) => void;
|
||||
/** Open the event's detail sheet. */
|
||||
onOpen: (id: string) => void;
|
||||
shown: StripShown;
|
||||
/** Whether there is any weekly or monthly task to switch: a switch with nothing behind it is not offered. */
|
||||
hasTasks: { weekly: boolean; monthly: boolean };
|
||||
onShown: (patch: Partial<StripShown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events by lane: the reader's order first, then any lane only an event brought
|
||||
* — the rule `dailyGroups` follows — and never re-sorted inside a lane. A lane
|
||||
* with nothing in it is dropped. Pure.
|
||||
*/
|
||||
export function laneGroups(
|
||||
games: readonly LaneId[],
|
||||
events: readonly StripEvent[],
|
||||
): { game: LaneId; events: StripEvent[] }[] {
|
||||
const lanes: LaneId[] = [...games];
|
||||
for (const event of events) if (!lanes.includes(event.game)) lanes.push(event.game);
|
||||
return lanes
|
||||
.map((game) => ({ game, events: events.filter((e) => e.game === game) }))
|
||||
.filter((group) => group.events.length > 0);
|
||||
}
|
||||
|
||||
export function EditPanel({
|
||||
games,
|
||||
editor,
|
||||
initialOpen = null,
|
||||
initialAdding = null,
|
||||
}: {
|
||||
games: readonly LaneId[];
|
||||
editor: StripEditor;
|
||||
/** The row open on first render; for tests, which cannot press a button. */
|
||||
initialOpen?: string | null;
|
||||
/** The game whose add list is open on first render; for tests, likewise. */
|
||||
initialAdding?: LaneId | null;
|
||||
}) {
|
||||
const gameMeta = useGameMeta();
|
||||
/** Which event's goal is open, by id. One at a time, as with the amount editor. */
|
||||
const [open, setOpen] = useState<string | null>(initialOpen);
|
||||
/**
|
||||
* The game whose add list is showing. Closed by default and one at a time:
|
||||
* a reader with every game on has a hundred running events, and listing them
|
||||
* all at once buried the strip's own events under a wall of pills.
|
||||
*/
|
||||
const [adding, setAdding] = useState<LaneId | null>(initialAdding);
|
||||
const { inStrip, addable, shown, hasTasks } = editor;
|
||||
|
||||
const add = (event: StripEvent) => {
|
||||
editor.onDaily(event.id, dailyOverride(true, event.detected));
|
||||
// Straight to its goal: adding one is nearly always so as to give it one.
|
||||
setOpen(event.id);
|
||||
};
|
||||
|
||||
const groups = laneGroups(games, [...inStrip, ...addable]);
|
||||
|
||||
return (
|
||||
<div className="relative mt-3 flex flex-col gap-3">
|
||||
{groups.map(({ game, events }) => {
|
||||
const meta = gameMeta(game);
|
||||
const here = events.filter((e) => inStrip.includes(e));
|
||||
const more = events.filter((e) => addable.includes(e));
|
||||
return (
|
||||
<div key={game}>
|
||||
<p className="text-[0.6875rem] font-semibold" style={{ color: meta.hue }}>
|
||||
{meta.name}
|
||||
</p>
|
||||
{here.map((event) => {
|
||||
const isOpen = open === event.id;
|
||||
return (
|
||||
<div key={event.id} className="mt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : event.id)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-baseline justify-between gap-3 text-left text-xs"
|
||||
>
|
||||
<span className="truncate text-muted">{event.title}</span>
|
||||
<span className="tnum shrink-0 text-faint">
|
||||
{/* Unreadable is not "none": a newer version's goal is
|
||||
still stored, and saying "No goal" would invite the
|
||||
reader to think there is nothing to lose. */}
|
||||
{event.goal === undefined && event.hasStoredGoal
|
||||
? "A goal this version can't read"
|
||||
: goalSummary(event.goal)}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="mb-2 ml-3 border-l border-hairline pl-3">
|
||||
<GoalControl
|
||||
goal={event.goal}
|
||||
defaultWeekStartsOn={event.defaultWeekStartsOn}
|
||||
maxDays={event.maxDays}
|
||||
onChange={(goal) => editor.onGoal(event.id, goal)}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.onOpen(event.id)}
|
||||
className="text-muted transition-colors hover:text-ink"
|
||||
>
|
||||
Open details
|
||||
</button>
|
||||
{/* The sheet's "not a daily" control and wording: the
|
||||
goal goes with it, so the two never contradict. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.onDaily(event.id, dailyOverride(false, event.detected), true);
|
||||
setOpen(null);
|
||||
}}
|
||||
className="text-faint transition-colors hover:text-muted"
|
||||
>
|
||||
{event.hasStoredGoal
|
||||
? "Remove from dailies — removes the goal"
|
||||
: "Remove from dailies"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{more.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(adding === game ? null : game)}
|
||||
aria-expanded={adding === game}
|
||||
className="mt-1.5 text-xs text-faint transition-colors hover:text-muted"
|
||||
>
|
||||
Add an event · {more.length}
|
||||
</button>
|
||||
)}
|
||||
{more.length > 0 && adding === game && (
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{more.map((event) => (
|
||||
<li key={event.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => add(event)}
|
||||
aria-label={`Add ${event.title} to dailies`}
|
||||
className="flex max-w-[15rem] items-center gap-1.5 rounded-full border border-dashed border-hairline px-3 py-1.5 text-xs text-faint transition-colors hover:border-faint hover:text-muted"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden className="size-3 shrink-0">
|
||||
<path
|
||||
d="M8 3v10M3 8h10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="truncate">{event.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{addable.length === 0 && (
|
||||
<p className="text-[0.6875rem] leading-relaxed text-faint">
|
||||
Every running event is already here. One that hasn't started joins the list to add once
|
||||
it opens.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The same prefs the settings' What you see group writes: one answer,
|
||||
reachable from where its effect is. */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 border-t border-hairline pt-3 text-xs text-muted">
|
||||
<Switch
|
||||
label="Daily chores"
|
||||
checked={shown.chores}
|
||||
onChange={(chores) => editor.onShown({ chores })}
|
||||
/>
|
||||
{hasTasks.weekly && (
|
||||
<Switch
|
||||
label="Weekly tasks"
|
||||
checked={shown.weekly}
|
||||
onChange={(weekly) => editor.onShown({ weekly })}
|
||||
/>
|
||||
)}
|
||||
{hasTasks.monthly && (
|
||||
<Switch
|
||||
label="Monthly tasks"
|
||||
checked={shown.monthly}
|
||||
onChange={(monthly) => editor.onShown({ monthly })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Switch({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="accent-[var(--color-near)]"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user