Saving before major structural changes are made

This commit is contained in:
Akaswan 2025-07-28 22:45:48 -05:00
parent 529b9b9631
commit e8debb4535
8 changed files with 296 additions and 55 deletions

View file

@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTableContext } from "../views/TableView";
import Table from "./Table";
import TopBar from "./TopBar";
import { format, addDays, subDays, isSameDay } from "date-fns";
import { addDays, subDays } from "date-fns";
import { createClient } from "@supabase/supabase-js";
import { Project, TableData, Task, TaskStatus } from "../types";
@ -13,9 +13,27 @@ const supabaseAnonKey = process.env.SUPABASE_ANON_KEY ?? "";
const supabase = createClient(supabaseUrl, supabaseAnonKey);
/** Utility Functions **/
const getWeekDates = (startDate: Date, numDates: number) =>
export const toMidnight = (date: Date | string): Date => {
const d = new Date(date);
d.setHours(0, 0, 0, 0); // set time to 00:00 UTC
return d;
};
const normalizeHours = (d: Date) => {
const copy = new Date(d);
copy.setHours(0, 0, 0, 0);
return copy;
};
const isBeforeNormalizedDay = (a: Date, b: Date) =>
normalizeHours(a).getTime() < normalizeHours(b).getTime();
const isSameNormalizedDay = (a: Date, b: Date) =>
normalizeHours(a).getTime() === normalizeHours(b).getTime();
const getWeekDates = (startDate: Date, numDates: number): Date[] =>
Array.from({ length: numDates }, (_, i) =>
format(addDays(startDate, i), "yyyy-MM-dd")
addDays(startDate, i)
);
const getTaskStatuses = (): TaskStatus[] => [
@ -85,6 +103,7 @@ const App: React.FC = () => {
const setDatesToThisWeek = () => {
const today = new Date();
console.log(today);
setBaseDate(today);
setDates(getWeekDates(today, datesShown));
};
@ -153,10 +172,11 @@ const App: React.FC = () => {
};
const moveTask = (id: number, newDate: string) => {
const normalizedDate = toMidnight(newDate);
const newProjects = projects.map((p) => ({
...p,
tasks: p.tasks.map((t) =>
t.id === id ? { ...t, date: new Date(newDate) } : t
t.id === id ? { ...t, date: normalizedDate } : t
),
}));
saveSpecificData("projects", newProjects);
@ -182,11 +202,11 @@ const App: React.FC = () => {
setTimeout(() => ref.current?.focus(), 0);
};
const addTaskToProject = (project: Project, date: string) => {
const addTaskToProject = (project: Project, date: Date) => {
const newTask: Task = {
id: nextTaskId,
name: "",
date: new Date(date),
date: toMidnight(date),
parentProjectId: project.id,
status: taskStatuses[0],
};
@ -202,25 +222,15 @@ const App: React.FC = () => {
setNextTaskId(newId);
};
const normalizeDateUTC = (d: Date) => {
const copy = new Date(d);
copy.setUTCHours(0, 0, 0, 0);
return copy;
};
const isBeforeDayUTC = (a: Date, b: Date) =>
normalizeDateUTC(a).getTime() < normalizeDateUTC(b).getTime();
const findPendingTasksLeftSide = () => {
let number = 0;
projects.forEach((project) => {
project.tasks.forEach((task) => {
if (
isBeforeDayUTC(new Date(task.date), baseDate) &&
isBeforeNormalizedDay(new Date(task.date), baseDate) &&
task.status.id !== "completed"
) {
number++;
console.log(new Date(task.date));
}
});
});
@ -311,6 +321,10 @@ const isBeforeDayUTC = (a: Date, b: Date) =>
/** Effects **/
useEffect(() => {
dataRef.current = data;
// dates.forEach((date) => {
// console.log(new Date(date));
// });
console.log(toMidnight(new Date()));
}, [data]);
useEffect(() => {
syncWithServer();
@ -325,7 +339,7 @@ const isBeforeDayUTC = (a: Date, b: Date) =>
projects.forEach((project) => {
project.tasks.forEach((task) => {
if (
isSameDay(new Date(task.date), subDays(new Date(), 1)) &&
isSameNormalizedDay(new Date(task.date), new Date()) &&
task.status.id !== "completed"
) {
remainingTasks++;
@ -384,6 +398,7 @@ const isBeforeDayUTC = (a: Date, b: Date) =>
editTaskStatus={editTaskStatus}
moveTask={moveTask}
wrapperRef={wrapperRef}
sharedState={tableContext!.sharedState}
/>
</div>
);

View file

@ -1,9 +1,11 @@
import { format, parseISO } from "date-fns";
import { format, isSameDay } from "date-fns";
import { useRef } from "react";
import * as React from "react";
import TaskCell from "./TaskCell";
import { DndContext, useDroppable, DragEndEvent } from "@dnd-kit/core";
import { Project, TaskStatus } from "../types";
import { IntraViewData } from "../main";
import { SharedState } from "../sharedState";
interface TableProps {
projects: Project[];
@ -13,8 +15,8 @@ interface TableProps {
newName: string,
newProjectInputRef: React.RefObject<HTMLInputElement | null>
) => void;
dates: string[];
addTaskToProject: (project: Project, date: string) => void;
dates: Date[];
addTaskToProject: (project: Project, date: Date) => void;
removeProject: (id: number) => void;
removeTask: (id: number) => void;
nextTaskId: number;
@ -23,6 +25,7 @@ interface TableProps {
editTaskStatus: (id: number, newStatusId: string) => void;
moveTask: (taskId: number, newDate: string) => void;
wrapperRef: React.RefObject<HTMLDivElement | null>;
sharedState: SharedState<IntraViewData> | null;
}
function DroppableCell({
@ -62,11 +65,21 @@ const Table: React.FC<TableProps> = ({
taskStatuses,
editTaskStatus,
moveTask,
wrapperRef,
sharedState,
}) => {
const newProjectInputRef = useRef<HTMLInputElement | null>(null);
const newTaskInputRef = useRef<HTMLTextAreaElement | null>(null);
const isTaskAutoFocused = (taskId: number, taskName: string) => {
if (taskId === nextTaskId - 1 && taskName === "") {
sharedState?.set({ selectedTaskId: taskId });
console.log("Auto focusing task:", taskId);
return true;
} else {
return false;
}
};
return (
<DndContext
onDragEnd={(event: DragEndEvent) => {
@ -86,18 +99,17 @@ const Table: React.FC<TableProps> = ({
<tr>
<th>Projects</th>
{dates.map((date) => {
const dateObj = parseISO(date);
return (
<th className="date" key={date}>
<th
className="date"
key={format(date, "yyyy-MM-dd")}
>
<div className="date-header">
<div>
{format(dateObj, "EEEE")}
{format(date, "EEEE")}
</div>
<div>
{format(
dateObj,
"yyyy-MM-dd"
)}
{format(date, "yyyy-MM-dd")}
</div>
</div>
</th>
@ -134,7 +146,7 @@ const Table: React.FC<TableProps> = ({
}
/>
</td>
{dates.map((date, index) => (
{dates.map((date) => (
<DroppableCell
id={`${project.id}::${date}`}
onClick={(e) => {
@ -150,23 +162,23 @@ const Table: React.FC<TableProps> = ({
}, 0);
}
}}
key={`${project.id}-${date}`} // ADD KEY HERE
key={`${project.id}-${date}`}
>
{project.tasks
.filter(
(task) =>
new Date(
task.date
).toISOString() ===
new Date(
date
).toISOString()
.filter((task) =>
isSameDay(
new Date(task.date),
new Date(date)
)
)
.map((task) => (
<TaskCell
key={task.id}
task={task}
autoFocus={task.id === nextTaskId - 1 && task.name === ""}
autoFocus={isTaskAutoFocused(
task.id,
task.name
)}
projectName={
project.name
}
@ -187,6 +199,11 @@ const Table: React.FC<TableProps> = ({
editTaskStatus={
editTaskStatus
}
onFocus={(taskId) => {
sharedState?.set({
selectedTaskId: taskId,
});
}}
/>
))}
</DroppableCell>

View file

@ -47,6 +47,7 @@ interface TaskCellProps {
taskStatuses: TaskStatus[];
editTaskStatus: (id: number, newStatusId: string) => void;
autoFocus?: boolean;
onFocus: (taskId: number) => void;
}
const TaskCell: React.FC<TaskCellProps> = ({
@ -59,6 +60,7 @@ const TaskCell: React.FC<TaskCellProps> = ({
taskStatuses,
editTaskStatus,
autoFocus = false, // default to false if not provided
onFocus,
}) => {
const taskBackground = `${status.color}33`;
const lightTaskTextColor = transColor(status.color, 25);
@ -175,6 +177,7 @@ const TaskCell: React.FC<TaskCellProps> = ({
height: "auto",
}}
rows={1}
onFocus={() => onFocus(task.id)}
/>
<style>{`
.task-input-${task.id}::placeholder {

View file

@ -0,0 +1,46 @@
import { useContext, useState } from "react";
import { TaskEditorContext } from "../views/TaskEditorView";
import { useSharedState } from "../sharedState";
import { TableData } from "../types";
const TaskEditor: React.FC = () => {
const ctx = useContext(TaskEditorContext);
const shared = ctx?.sharedState;
const state = shared ? useSharedState(shared) : null;
if (!state) return <div>Loading...</div>;
const getInitialData = (): TableData => {
const loaded = ctx?.loadData?.();
if (
loaded &&
typeof loaded === "object" &&
"projects" in loaded &&
"nextProjectId" in loaded &&
"nextTaskId" in loaded &&
"lastUpdated" in loaded
) {
return loaded as TableData;
}
return {
projects: [],
nextProjectId: 1,
nextTaskId: 1,
lastUpdated: new Date().toISOString(),
};
};
const [data, setData] = useState<TableData>(getInitialData);
const tasks = data.projects.flatMap(project => project.tasks);
const selectedTask = tasks.find(task => task.id === state.selectedTaskId);
return (
<div className="task-editor">
<p>{selectedTask?.name}</p>
</div>
);
};
export default TaskEditor;

View file

@ -1,6 +1,13 @@
import { Plugin, WorkspaceLeaf } from "obsidian";
import { TableView, TABLE_VIEW_TYPE } from "./views/TableView";
import { TableListSettingsTab } from "./settings";
import { TASK_EDITOR_VIEW_TYPE } from "./views/TaskEditorView";
import { TaskEditorView } from "./views/TaskEditorView";
import { createSharedState } from "./sharedState";
export interface IntraViewData {
selectedTaskId: number | null;
}
export interface TableListSettings {
maxDates: string;
@ -13,6 +20,10 @@ const DEFAULT_SETTINGS: Partial<TableListSettings> = {
export default class TableList extends Plugin {
settings: TableListSettings;
sharedState = createSharedState<IntraViewData>({
selectedTaskId: null,
});
async loadSettings() {
this.settings = Object.assign(
{},
@ -34,7 +45,15 @@ export default class TableList extends Plugin {
const statusBarNotifier = this.addStatusBarItem();
const statusBarText = statusBarNotifier.createEl('span');
const statusBarText = statusBarNotifier.createEl("span");
this.addCommand({
id: "open-table-list",
name: "Open table list",
callback: () => {
this.activateTableView();
},
});
if (!(await data)) {
data = {
@ -62,24 +81,34 @@ export default class TableList extends Plugin {
() => data,
(data) => this.saveData(data),
this.settings,
statusBarText
statusBarText,
this.sharedState
)
);
this.addRibbonIcon("table-2", "Activate view", () => {
this.activateView();
this.addRibbonIcon("table-2", "Open table list", () => {
this.activateTableView();
});
// if (!this.app.vault.getFolderByPath("TableList")) {
// this.app.vault.createFolder("TableList");
// } else {
// console.log("Folder already exists");
// }
this.addRibbonIcon("rectangle-horizontal", "Open task editor", () => {
this.activateTaskEditView();
});
this.registerView(
TASK_EDITOR_VIEW_TYPE,
(leaf) =>
new TaskEditorView(
leaf,
this.sharedState,
() => data,
(data) => this.saveData(data)
)
);
}
async onunload() {}
async activateView() {
async activateTableView() {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
@ -105,4 +134,31 @@ export default class TableList extends Plugin {
workspace.revealLeaf(leaf);
}
}
async activateTaskEditView() {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(TASK_EDITOR_VIEW_TYPE);
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0];
} else {
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({
type: TASK_EDITOR_VIEW_TYPE,
active: true,
});
}
}
// "Reveal" the leaf in case it is in a collapsed sidebar
if (leaf) {
workspace.revealLeaf(leaf);
}
}
}

32
src/main/sharedState.ts Normal file
View file

@ -0,0 +1,32 @@
import { useSyncExternalStore } from "react";
export interface SharedState<T> {
get: () => T;
set: (next: Partial<T>) => void;
subscribe: (callback: () => void) => () => void;
}
export function createSharedState<T extends object>(initialState: T): SharedState<T> {
let current = { ...initialState };
const listeners = new Set<() => void>();
function get() {
return current;
}
function set(partial: Partial<T>) {
current = { ...current, ...partial };
for (const listener of listeners) listener();
}
function subscribe(callback: () => void) {
listeners.add(callback);
return () => listeners.delete(callback);
}
return { get, set, subscribe };
}
export function useSharedState<T>(shared: SharedState<T>): T {
return useSyncExternalStore(shared.subscribe, shared.get, shared.get);
}

View file

@ -2,21 +2,23 @@ import { createContext, useContext } from "react";
import { ItemView, WorkspaceLeaf, App } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import AppComponent from "src/main/components/App";
import { TableListSettings } from "../main";
import { IntraViewData, TableListSettings } from "../main";
import { SharedState } from "../sharedState";
export interface AppContext {
export interface TableViewContext {
app: App;
loadData: () => unknown;
saveData: (data: unknown) => Promise<void>;
settings: TableListSettings;
statusBarText: HTMLSpanElement;
sharedState: SharedState<IntraViewData>;
}
export const TABLE_VIEW_TYPE = "table-view";
export const TableContext = createContext<AppContext | undefined>(undefined);
export const TableContext = createContext<TableViewContext | undefined>(undefined);
export const useTableContext = (): AppContext | undefined => {
export const useTableContext = (): TableViewContext | undefined => {
return useContext(TableContext);
};
@ -27,19 +29,22 @@ export class TableView extends ItemView {
saveData: (data: unknown) => Promise<void>;
settings: TableListSettings;
statusBarText: HTMLSpanElement;
sharedState: SharedState<IntraViewData>;
constructor(
leaf: WorkspaceLeaf,
loadData: () => unknown,
saveData: (data: unknown) => Promise<void>,
settings: TableListSettings,
statusBarText: HTMLSpanElement
statusBarText: HTMLSpanElement,
sharedState: SharedState<IntraViewData>
) {
super(leaf);
this.loadData = loadData;
this.saveData = saveData;
this.settings = settings;
this.statusBarText = statusBarText;
this.sharedState = sharedState;
}
getViewType() {
@ -64,6 +69,7 @@ export class TableView extends ItemView {
loadData: this.loadData,
settings: this.settings,
statusBarText: this.statusBarText,
sharedState: this.sharedState,
}}
>
<AppComponent />

View file

@ -0,0 +1,66 @@
import { createContext } from "react";
import { ItemView, WorkspaceLeaf } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import TaskEditor from "../components/TaskEditor";
import { SharedState } from "../sharedState";
import { IntraViewData } from "../main";
export const TASK_EDITOR_VIEW_TYPE = "task-editor-sidebar-view";
// Context shared between React components
export const TaskEditorContext = createContext<{
loadData: () => unknown;
saveData: (data: unknown) => Promise<void>;
sharedState: SharedState<IntraViewData>;
} | null>(null);
export class TaskEditorView extends ItemView {
root: Root | null = null;
loadData: () => unknown;
saveData: (data: unknown) => Promise<void>;
sharedState: SharedState<IntraViewData>;
constructor(
leaf: WorkspaceLeaf,
sharedState: SharedState<IntraViewData>,
loadData: () => unknown,
saveData: (data: unknown) => Promise<void>
) {
super(leaf);
this.loadData = loadData;
this.saveData = saveData;
this.sharedState = sharedState;
}
getViewType() {
return TASK_EDITOR_VIEW_TYPE;
}
getDisplayText() {
return "Task Editor";
}
getIcon() {
return "rectangle-horizontal";
}
async onOpen() {
this.root = createRoot(this.containerEl.children[1]);
this.root.render(
<TaskEditorContext.Provider
value={{
sharedState: this.sharedState,
loadData: this.loadData,
saveData: this.saveData,
}}
>
<TaskEditor />
</TaskEditorContext.Provider>
);
}
async onClose() {
this.root?.unmount();
}
}