A simple to-do app focused on tasks that can be completed within a specific time span.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge remote-tracking branch 'github/main'

+242 -1
+37
app/components/Settings/SettingsSheet.vue
··· 8 8 9 9 const isOpen = defineModel<boolean>("isOpen", { required: true }); 10 10 11 + const config = useRuntimeConfig(); 12 + 11 13 function close() { 12 14 isOpen.value = false; 13 15 } ··· 24 26 console.error("Unknown locale"); 25 27 } 26 28 } 29 + 30 + async function sendPush() { 31 + const permission = await Notification.requestPermission(); 32 + 33 + if (permission !== "granted") return; 34 + 35 + const reg = await navigator.serviceWorker.ready; 36 + 37 + const sub = await reg.pushManager.subscribe({ 38 + userVisibleOnly: true, 39 + applicationServerKey: config.public.subscriptionsPublicKey, 40 + }); 41 + 42 + const fetch = useRequestFetch(); 43 + 44 + await fetch(`/api/subscription/`, { 45 + method: "POST", 46 + body: sub.toJSON(), 47 + ...useFetchOptions(), 48 + }).catch(async (err) => { 49 + console.warn(err); 50 + }); 51 + } 27 52 </script> 28 53 29 54 <template> ··· 60 85 <option value="top">{{ t(`top`) }}</option> 61 86 <option value="bottom">{{ t(`bottom`) }}</option> 62 87 </select> 88 + </div> 89 + 90 + <div> 91 + <h2 class="text-muted-text text-lg">{{ t("push") }}</h2> 92 + <button 93 + type="button" 94 + data-testid="add-label-button" 95 + class="bg-surface border-secondary h-8 cursor-pointer rounded border px-2 text-white" 96 + @click="sendPush()" 97 + > 98 + {{ t("push") }} 99 + </button> 63 100 </div> 64 101 </div> 65 102 <Categories />
+14
app/pages/index.vue
··· 24 24 }); 25 25 26 26 const { isMobile } = useDevice(); 27 + 28 + onMounted(async () => { 29 + if ("serviceWorker" in navigator) { 30 + try { 31 + const reg = await navigator.serviceWorker.register("/sw.js"); 32 + 33 + await navigator.serviceWorker.ready; 34 + 35 + console.log("SW ist bereit und aktiv:", reg.active); 36 + } catch (err) { 37 + console.error("SW registration failed", err); 38 + } 39 + } 40 + }); 27 41 </script> 28 42 <template> 29 43 <div>
+3 -1
i18n/locales/de.json
··· 33 33 "recurring-weekly": "Wöchentlich", 34 34 "tagSelect": { 35 35 "notUsedYet": "Nicht verwendet" 36 - } 36 + }, 37 + "recurring-weekly": "Wöchentlich", 38 + "push": "Webpush" 37 39 }
+2
i18n/locales/en.json
··· 31 31 "recurring-none": "None", 32 32 "recurring-daily": "Daily", 33 33 "recurring-weekly": "Weekly", 34 + "push": "Webpush", 35 + "recurring-weekly": "Weekly", 34 36 "tagSelect": { 35 37 "notUsedYet": "Not used yet" 36 38 }
+12
nuxt.config.ts
··· 37 37 }, 38 38 }, 39 39 runtimeConfig: { 40 + public: { 41 + subscriptionsPublicKey: 42 + process.env.NUXT_PUBLIC_SUBSCRIPTIONS_PUBLIC_KEY ?? "", 43 + }, 40 44 upstash: { 41 45 url: process.env.UPSTASH_URL, 42 46 token: process.env.UPSTASH_TOKEN, ··· 50 54 { code: "de", name: "German", file: "de.json" }, 51 55 ], 52 56 defaultLocale: "en", 57 + }, 58 + nitro: { 59 + experimental: { 60 + tasks: true, 61 + }, 62 + scheduledTasks: { 63 + "* * * * *": ["notify:push"], 64 + }, 53 65 }, 54 66 });
+2
package.json
··· 40 40 "vaul-vue": "^0.4.1", 41 41 "vue": "^3.5.33", 42 42 "vue-router": "^5.0.6", 43 + "web-push": "^3.6.7", 43 44 "zod": "^4.3.6" 44 45 }, 45 46 "devDependencies": { 46 47 "@biomejs/biome": "^2.4.13", 47 48 "@nuxt/test-utils": "^4.0.2", 48 49 "@playwright/test": "^1.59.1", 50 + "@types/web-push": "^3.6.4", 49 51 "@vue/test-utils": "^2.4.6", 50 52 "happy-dom": "^20.9.0", 51 53 "typescript": "^6.0.3",
+9
public/sw.js
··· 1 + self.addEventListener("push", (event) => { 2 + const data = event.data.json(); 3 + 4 + event.waitUntil( 5 + self.registration.showNotification(data.title, { 6 + body: data.body, 7 + }), 8 + ); 9 + });
+14
server/api/subscription/.post.ts
··· 1 + import { SubscriptionEventStream } from "~~/server/utils/sse"; 2 + import { PushSubscriptionJSON } from "~~/shared/types"; 3 + 4 + export default defineAuthenticatedEventHandler( 5 + async (event, userId): Promise<PushSubscriptionJSON> => { 6 + const body = await readValidatedBody(event, PushSubscriptionJSON.parse); 7 + 8 + const subscription = await Subscriptions.updateOrAdd(userId, body); 9 + if (subscription instanceof Error) throw subscription; 10 + 11 + SubscriptionEventStream.sendUpdate(userId); 12 + return subscription; 13 + }, 14 + );
+14
server/plugins/storage.ts
··· 52 52 base: ".data/categories", 53 53 }), 54 54 ); 55 + storage.mount( 56 + "subscriptions", 57 + fsDriver({ 58 + base: ".data/subscriptions", 59 + }), 60 + ); 55 61 } 56 62 57 63 function defineProductionStorage() { ··· 79 85 url: useRuntimeConfig().upstash.url, 80 86 token: useRuntimeConfig().upstash.token, 81 87 base: "categories", 88 + }), 89 + ); 90 + storage.mount( 91 + "subscriptions", 92 + upstashDriver({ 93 + url: useRuntimeConfig().upstash.url, 94 + token: useRuntimeConfig().upstash.token, 95 + base: "subscriptions", 82 96 }), 83 97 ); 84 98 }
+11
server/tasks/notify/push.ts
··· 1 + export default defineTask({ 2 + async run(_event) { 3 + await Todos.sendMsg(); 4 + 5 + const result = { 6 + sent: 10, 7 + }; 8 + 9 + return { result }; 10 + }, 11 + });
+1
server/types/web-push.d.ts
··· 1 + declare module "web-push";
+62
server/utils/db/subscription.ts
··· 1 + import type { NuxtError } from "nuxt/app"; 2 + import webpush from "web-push"; 3 + import type { PushSubscriptionJSON } from "~~/shared/types"; 4 + 5 + function getKey(userId: string): string { 6 + return `subscriptions:${userId}`; 7 + } 8 + 9 + webpush.setVapidDetails( 10 + "mailto:you@example.com", 11 + process.env.NUXT_PUBLIC_SUBSCRIPTIONS_PUBLIC_KEY!, 12 + process.env.SUBSCRIPTIONS_PRIVATE_KEY!, 13 + ); 14 + 15 + export const Subscriptions = { 16 + async getAll(userId: string): Promise<PushSubscriptionJSON[]> { 17 + const storage = useStorage(); 18 + return (await storage.get<PushSubscriptionJSON[]>(getKey(userId))) ?? []; 19 + }, 20 + async updateOrAdd( 21 + userId: string, 22 + subscription: PushSubscriptionJSON, 23 + ): Promise<PushSubscriptionJSON | NuxtError> { 24 + const storage = useStorage(); 25 + const subscriptions = await Subscriptions.getAll(userId); 26 + 27 + const exists = subscriptions.find( 28 + (s) => 29 + s.endpoint === subscription.endpoint || 30 + (s.keys.p256dh === subscription.keys.p256dh && 31 + s.keys.auth === subscription.keys.auth), 32 + ); 33 + 34 + if (!exists) { 35 + subscriptions.push(subscription); 36 + await storage.set(getKey(userId), subscriptions); 37 + return subscription; 38 + } 39 + 40 + return createError({ 41 + status: 409, 42 + statusMessage: "Conflict", 43 + message: `Subscription already exists`, 44 + }); 45 + }, 46 + async sendNotification(userId: string, title: string, body: string) { 47 + const subscriptions = await Subscriptions.getAll(userId); 48 + 49 + const payload = JSON.stringify({ 50 + title: title, 51 + body: body, 52 + }); 53 + 54 + for (const sub of subscriptions) { 55 + try { 56 + await webpush.sendNotification(sub, payload); 57 + } catch (err) { 58 + console.error("Push failed", err); 59 + } 60 + } 61 + }, 62 + };
+37
server/utils/db/todos.ts
··· 13 13 return (await storage.get<Todo[]>(getKey(userId))) ?? []; 14 14 }, 15 15 16 + async sendMsg(): Promise<void> { 17 + const keys = await useStorage().getKeys(); 18 + const filteredKeys = keys.filter((key) => key.startsWith("todos:")); 19 + 20 + filteredKeys.forEach(async (key) => { 21 + const todos = await useStorage().get<Todo[]>(key); 22 + 23 + todos?.forEach((todo) => { 24 + if (todo.time?.type === "point") { 25 + const date = new Date(todo.time.time); 26 + const now = new Date(); 27 + 28 + const sameMinute = 29 + date.getUTCFullYear() === now.getUTCFullYear() && 30 + date.getUTCMonth() === now.getUTCMonth() && 31 + date.getUTCDate() === now.getUTCDate() && 32 + date.getUTCHours() === now.getUTCHours() && 33 + date.getUTCMinutes() === now.getUTCMinutes(); 34 + 35 + if (sameMinute) { 36 + Subscriptions.sendNotification( 37 + key.substring(6), 38 + todo.title, 39 + todo.note, 40 + ); 41 + } 42 + } 43 + }); 44 + }); 45 + }, 46 + 47 + async getAllWithTimeStamp(userId: string): Promise<Todo[]> { 48 + const todos = await Todos.getAll(userId); 49 + 50 + return todos.filter((todo) => todo.time?.type === "range"); 51 + }, 52 + 16 53 async move( 17 54 userId: string, 18 55 toMove: UUID,
+14
server/utils/sse.ts
··· 31 31 }, 32 32 }; 33 33 34 + export const SubscriptionEventStream = { 35 + addStream(userId: string, eventStream: EventStream) { 36 + addEventStream(userId, subscriptionsEventStreams, eventStream); 37 + }, 38 + async sendUpdate(userId: string) { 39 + await publish( 40 + userId, 41 + subscriptionsEventStreams, 42 + await Subscriptions.getAll(userId), 43 + ); 44 + }, 45 + }; 46 + 34 47 const todosEventStreams: Map<string, EventStream[]> = new Map(); 35 48 const tagsEventStreams: Map<string, EventStream[]> = new Map(); 36 49 const categoriesEventStreams: Map<string, EventStream[]> = new Map(); 50 + const subscriptionsEventStreams: Map<string, EventStream[]> = new Map(); 37 51 38 52 function addEventStream( 39 53 key: string,
+10
shared/types.ts
··· 43 43 ]); 44 44 export type Time = z.infer<typeof Time>; 45 45 46 + export const PushSubscriptionJSON = z.object({ 47 + endpoint: z.string(), 48 + expirationTime: z.number().nullable().optional(), 49 + keys: z.object({ 50 + auth: z.string(), 51 + p256dh: z.string(), 52 + }), 53 + }); 54 + export type PushSubscriptionJSON = z.infer<typeof PushSubscriptionJSON>; 55 + 46 56 export const Todo = z.object({ 47 57 uuid: z.uuid(), 48 58 title: z.string().min(1),