Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ each song as a revisioned custom post, and exposes it under a URL slug you
choose (default `/troche`). Viewing requires being logged in; editing requires
a capability you grant per user on **Settings → Troche**.

Because saving is per song rather than per library, having the app open on two
machines is safe: when a tab comes back into view, and again before it saves, it
checks which songs have moved and folds in anything it hasn't touched itself.
Only a song edited in both places at once needs a decision, and it says so
rather than overwriting quietly. Saves are conditional on the version the tab
last saw, so even a write that races another machine is refused and turned into
that same question.

### Build

```bash
Expand Down
16 changes: 14 additions & 2 deletions docs/wp-storage-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,20 @@ subpath and the plugin directory.
- **Autosave replaces the Save button:** ~10s debounce, fires only when dirty,
flush on `visibilitychange`/`pagehide`. Status indicator: "Saved ✓ /
Saving… / Offline — changes kept locally."
- **Conflicts: last write wins**; revisions are the safety net. Dirty-only
saving means idle open tabs never clobber.
- **Conflicts: reconcile per song, prompt only when it's genuinely ambiguous.**
Dirty-only saving already means idle open tabs never clobber. On top of that,
the app checks `GET /library/state` (version tokens, no content) when a tab
becomes visible and again before each save. A song changed on another machine
that this tab hasn't touched is adopted silently; one added there is pulled
in; one trashed there is dropped. Only a song edited in *both* places, or
edited here and trashed there, surfaces to the user — those are held back
from saving until resolved, while the rest of the library keeps autosaving.
Whichever copy loses is still recoverable from the song's revisions.
Checking before a save narrows the window but can't close it — the check and
the write aren't one operation — so `PUT` is conditional: it carries the token
the client last saw in `X-Troche-Expect-Token` and the server refuses with 409
if the song moved in between. A refusal reconciles and surfaces the same
per-song choice, so a lost race is a question, never a silent overwrite.
- **Share/Export/Import stay functionally untouched** in both modes (one
codebase; they're client-side and cost nothing). Export/Import double as the
seeding path: each bandmate exports their existing localStorage library and
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
"build": "vite build",
"build:wp": "vite build && node scripts/sync-dist.mjs",
"preview": "vite preview",
"test": "npm run test:unit && npm run test:plugin && npm run test:zip",
"test": "npm run test:unit && npm run test:sync && npm run test:plugin && npm run test:zip",
"test:unit": "node tests/unit.mjs",
"test:sync": "node tests/sync.mjs",
"test:plugin": "bash tests/plugin.sh",
"test:zip": "bash tests/zip-install.sh"
},
Expand Down
142 changes: 139 additions & 3 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Music, Plus, RotateCcw } from "lucide-react";
import { PALETTE } from "./constants.js";
import { uid, normalizeLibrary } from "./utils.js";
import { uid, normalizeLibrary, mergeFlags } from "./utils.js";
import { defaultLibrary, defaultSong } from "./defaults.js";
import {
loadLibrary,
saveLibrary,
syncUpstream,
releaseHold,
forgetHandle,
cacheLibrary,
clearBuffer,
wpMode,
canEdit,
Expand All @@ -17,6 +21,7 @@ import { Header } from "./components/Header.jsx";
import { Transport } from "./components/Transport.jsx";
import { PartBlock } from "./components/PartBlock.jsx";
import { PrintChart } from "./components/PrintChart.jsx";
import { SyncNotice } from "./components/SyncNotice.jsx";
import { styles, css } from "./styles.js";

// Matches the CSS mobile breakpoint (see styles.js). Used to drop the
Expand All @@ -35,6 +40,15 @@ function useIsMobile() {
return m;
}

// The app renders off an active song, so the library must never reach zero.
// A blank song stands in — the same fallback a fresh, empty install gets, and
// left un-dirty so it's never pushed to everyone's server on its own.
function ensureNonEmpty(lib) {
if (lib.songs.length) return lib;
const blank = defaultSong("New Song");
return { ...lib, songs: [blank], activeId: blank.id };
}

export default function App() {
const [library, setLibrary] = useState(null);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -71,6 +85,15 @@ export default function App() {
// brief confirmation that a share link was copied
const [shareFlash, setShareFlash] = useState(false);

// Songs another machine changed that need a decision before they can save
// again: { kind: "conflict" | "orphan", wpId, id, name, theirs? }. Their
// writes are held in storage.js until resolved; the rest of the library
// keeps autosaving normally.
const [flags, setFlags] = useState([]);

// Count of songs quietly pulled in from another machine, for a passing note.
const [pullFlash, setPullFlash] = useState(0);

useEffect(() => {
// Consume the shared payload (if any) before loading from storage. The
// hash is stripped regardless of parse success so the URL is clean.
Expand Down Expand Up @@ -321,12 +344,103 @@ export default function App() {
}));
}, []);

// Pull down anything another machine changed and fold it into local state.
// Songs this tab hasn't touched are adopted silently; only genuine conflicts
// surface. Returns the library to work from — the merged one when something
// came in, the current one otherwise.
const runSync = useCallback(async () => {
const current = libraryRef.current;
if (!wpMode || !current) return current;

const result = await syncUpstream(current);
if (!result) return current;
if (result.authExpired) {
setWpStatus("expired");
return current;
}

const merged = ensureNonEmpty(result.library);
setLibrary(merged);
setFlags((cur) => mergeFlags(cur, result.conflicts, result.orphans));
if (result.pulled) {
setPullFlash(result.pulled);
setTimeout(() => setPullFlash(0), 6000);
}
return merged;
}, []);

const dismissFlag = (wpId) => setFlags((cur) => cur.filter((f) => f.wpId !== wpId));

// Apply a resolution to local state and to the offline buffer together.
// Resolving in favour of the server leaves nothing to save — local already
// matches — and the buffer is otherwise only written by a save, so without
// this it would go on serving the copy the user just rejected to the next
// offline reload.
const applyResolution = (next) => {
setLibrary(next);
cacheLibrary(next);
};

// Conflict → keep this tab's copy. Releasing the hold lets the next save
// overwrite the other machine's version; that version stays recoverable from
// the song's revisions in wp-admin.
const keepMine = (flag) => {
releaseHold(flag.wpId);
dismissFlag(flag.wpId);
setDirty(true);
};

// Conflict → take the other machine's copy, dropping this tab's edits to that
// song. Its id stays local so the active-song selection survives.
const useTheirs = (flag) => {
releaseHold(flag.wpId);
const cur = libraryRef.current;
applyResolution({
...cur,
songs: cur.songs.map((s) => (s.id === flag.id ? { ...flag.theirs, id: s.id } : s)),
});
dismissFlag(flag.wpId);
};

// Orphan → keep a song that was trashed elsewhere. Dropping its server handle
// makes the next save re-create it rather than PUT to a trashed post.
const keepDeleted = (flag) => {
forgetHandle(flag.id, flag.wpId);
const cur = libraryRef.current;
applyResolution({
...cur,
songs: cur.songs.map((s) =>
s.id === flag.id ? (({ wpId, wpToken, ...rest }) => rest)(s) : s
),
});
dismissFlag(flag.wpId);
setDirty(true);
};

// Orphan → accept the deletion here too.
const discardDeleted = (flag) => {
releaseHold(flag.wpId);
const cur = libraryRef.current;
const songs = cur.songs.filter((s) => s.id !== flag.id);
const activeId = songs.some((s) => s.id === cur.activeId)
? cur.activeId
: songs[0]?.id ?? null;
applyResolution(ensureNonEmpty({ ...cur, songs, activeId }));
dismissFlag(flag.wpId);
};

// The single save path used by autosave, the manual Save button (local mode),
// and manual retries. Reads the latest library through the ref.
const runSave = useCallback(
async (opts = {}) => {
const lib = libraryRef.current;
let lib = libraryRef.current;
if (!lib) return;

// Reconcile first, so a save that has been sitting in the debounce window
// can't push over something that landed from another machine meanwhile.
if (wpMode) lib = await runSync();
if (!lib) return;

setSaving(true);
const res = await saveLibrary(lib, opts);
setSaving(false);
Expand All @@ -345,11 +459,15 @@ export default function App() {
setWpStatus("expired");
} else if (res.stale) {
setWpStatus("stale");
} else if (res.diverged) {
// A song was trashed elsewhere between the sync and the write. Resync
// to classify it and surface the choice.
runSync();
} else if (res.offline) {
setWpStatus("offline");
}
},
[adoptWpIds]
[adoptWpIds, runSync]
);

const handleSave = () => {
Expand All @@ -368,13 +486,20 @@ export default function App() {

// Best-effort flush when the tab is hidden or unloaded, so edits inside the
// debounce window aren't lost. WP saves use keepalive so they survive unload.
// Coming back to a visible tab is the other half: it's exactly the moment
// this laptop might have gone stale against another one, so reconcile then
// rather than polling in the background.
const syncRef = useRef(runSync);
syncRef.current = runSync;

useEffect(() => {
const flush = () => {
if (!dirtyRef.current || !libraryRef.current) return;
saveLibrary(libraryRef.current, { keepalive: true });
};
const onVis = () => {
if (document.visibilityState === "hidden") flush();
else syncRef.current();
};
document.addEventListener("visibilitychange", onVis);
window.addEventListener("pagehide", flush);
Expand Down Expand Up @@ -557,6 +682,8 @@ export default function App() {
? "expired"
: wpStatus === "stale"
? "stale"
: flags.length
? "conflict"
: wpStatus === "offline"
? "offline"
: dirty
Expand Down Expand Up @@ -606,6 +733,15 @@ export default function App() {
onReload={() => window.location.reload()}
/>

<SyncNotice
flags={flags}
pulled={pullFlash}
onKeepMine={keepMine}
onUseTheirs={useTheirs}
onKeepDeleted={keepDeleted}
onDiscardDeleted={discardDeleted}
/>

<Transport
playing={playing}
togglePlay={togglePlay}
Expand Down
1 change: 1 addition & 0 deletions src/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function StatusPill({ state, loginUrl, onSave, onReload }) {
offline: { icon: <WifiOff size={15} />, label: "Offline — changes kept locally" },
expired: { icon: <TriangleAlert size={15} />, label: "Session expired" },
stale: { icon: <TriangleAlert size={15} />, label: "Reload before saving" },
conflict: { icon: <TriangleAlert size={15} />, label: "Changed on another device" },
viewonly: { icon: <Eye size={15} />, label: "View only" },
};
const s = map[state] || map.saved;
Expand Down
64 changes: 64 additions & 0 deletions src/components/SyncNotice.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { TriangleAlert, ArrowDownToLine } from "lucide-react";

// Sits under the header in WP mode and reports what came in from another
// machine.
//
// Two very different messages share the strip. The pulled-songs line is
// informational and disappears on its own — those songs were adopted with no
// decision to make. The flags are blocking: each names a song whose saves are
// held until the user picks a side, so each carries its own two buttons rather
// than one library-wide "reload", which would throw away local edits.
export function SyncNotice({
flags,
pulled,
onKeepMine,
onUseTheirs,
onKeepDeleted,
onDiscardDeleted,
}) {
if (!flags.length && !pulled) return null;

return (
<div className="sa-sync">
{!!pulled && !flags.length && (
<div className="sa-sync-row info">
<ArrowDownToLine size={15} />
<span className="sa-sync-text">
Updated {pulled} song{pulled === 1 ? "" : "s"} from another device.
</span>
</div>
)}

{flags.map((flag) => (
<div className="sa-sync-row" key={flag.wpId}>
<TriangleAlert size={15} />
<span className="sa-sync-text">
<strong>{flag.name || "Untitled"}</strong>{" "}
{flag.kind === "orphan"
? "was deleted on another device, but you've edited it here."
: "was also edited on another device."}
</span>
{flag.kind === "orphan" ? (
<>
<button className="sa-btn ghost" onClick={() => onKeepDeleted(flag)}>
Keep mine
</button>
<button className="sa-btn ghost" onClick={() => onDiscardDeleted(flag)}>
Delete here too
</button>
</>
) : (
<>
<button className="sa-btn ghost" onClick={() => onKeepMine(flag)}>
Keep mine
</button>
<button className="sa-btn ghost" onClick={() => onUseTheirs(flag)}>
Use theirs
</button>
</>
)}
</div>
))}
</div>
);
}
Loading