A simple to-do app focused on tasks that can be completed within a specific time span. taskline.tobinio.dev/
1
fork

Configure Feed

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

This pr adds notifications #1

open opened by benni043.tngl.sh targeting main from benni043.tngl.sh/task-line: main
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:4d7tshmkdmu3kss6q2v5tzka/sh.tangled.repo.pull/3mkla7arkzh22
+218 -9
Diff #0
+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>
+9 -1
package.json
··· 22 22 "@iconify-json/material-symbols": "^1.2.64", 23 23 "@internationalized/date": "^3.12.0", 24 24 "@nuxt/icon": "^2.2.1", 25 - "@nuxtjs/i18n": "^10.2.4", 26 25 "@nuxtjs/device": "^4.0.0", 26 + "@nuxtjs/i18n": "^10.2.4", 27 27 "@nuxtjs/tailwindcss": "^7.0.0-beta.1", 28 28 "@pinia/nuxt": "^0.11.3", 29 29 "@upstash/redis": "^1.37.0", ··· 40 40 "vaul-vue": "^0.4.1", 41 41 "vue": "^3.5.31", 42 42 "vue-router": "^5.0.4", 43 + "web-push": "^3.6.7", 43 44 "zod": "^4.3.6" 44 45 }, 45 46 "devDependencies": { 47 + "@biomejs/biome": "^2.4.10", 48 + "@nuxt/test-utils": "^4.0.0", 49 + "@playwright/test": "^1.58.0", 50 + "@types/web-push": "^3.6.4", 51 + "@vue/test-utils": "^2.4.6", 52 + "happy-dom": "^20.8.9", 53 + "typescript": "^6.0.2",
+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 + await 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 ··· 81 87 base: "categories", 82 88 }), 83 89 ); 90 + storage.mount( 91 + "subscriptions", 92 + upstashDriver({ 93 + url: useRuntimeConfig().upstash.url, 94 + token: useRuntimeConfig().upstash.token, 95 + base: "subscriptions", 96 + }), 97 + ); 84 98 }
+46
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 + process.env.SUBSCRIPTIONS_MAIL!, 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: 201, 42 + statusMessage: "Already subscribed", 43 + message: `Subscription already exists`, 44 + }); 45 + } 46 + };
+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),
+16 -6
nuxt.config.ts
··· 36 36 37 37 38 38 39 - 40 - 41 - 42 - 43 - 44 - 39 + runtimeConfig: { 40 + public: { 41 + baseURL: process.env.BASE_URL ?? "", 42 + subscriptionsPublicKey: 43 + process.env.NUXT_PUBLIC_SUBSCRIPTIONS_PUBLIC_KEY ?? "", 44 + }, 45 + upstash: { 46 + url: process.env.UPSTASH_URL, 45 47 46 48 47 49 ··· 54 56 ], 55 57 defaultLocale: "en", 56 58 }, 59 + nitro: { 60 + experimental: { 61 + tasks: true, 62 + }, 63 + scheduledTasks: { 64 + "* * * * *": ["notify:push"], 65 + }, 66 + }, 57 67 });
+9
server/tasks/notify/push.ts
··· 1 + import {sendMsg} from "#server/utils/subscriptions"; 2 + 3 + export default defineTask({ 4 + async run(_event) { 5 + await sendMsg(); 6 + 7 + return { result: "Success" }; 8 + }, 9 + });
+1
server/utils/db/todos.ts
··· 15 15 16 16 async move( 17 17 userId: string, 18 + toMove: UUID,
+2 -1
i18n/locales/de.json
··· 30 30 "date-point": "Zeitpunkt", 31 31 "recurring-none": "Keine", 32 32 "recurring-daily": "Täglich", 33 - "recurring-weekly": "Wöchentlich" 33 + "recurring-weekly": "Wöchentlich", 34 + "push": "Webpush" 34 35 }
+2 -1
i18n/locales/en.json
··· 30 30 "date-point": "Date Point", 31 31 "recurring-none": "None", 32 32 "recurring-daily": "Daily", 33 - "recurring-weekly": "Weekly" 33 + "recurring-weekly": "Weekly", 34 + "push": "Webpush" 34 35 }
+8
README.md
··· 11 11 ```dotenv 12 12 GOOGLE_CLIENT_ID=<google_client_id> 13 13 GOOGLE_CLIENT_SECRET=<google_client_secret> 14 + 15 + NUXT_PUBLIC_SUBSCRIPTIONS_PUBLIC_KEY=<public_key> 16 + SUBSCRIPTIONS_PRIVATE_KEY=<private_key> 17 + SUBSCRIPTIONS_MAIL=<mailto:you@example.com> 14 18 ``` 15 19 16 20 ### Deployment ··· 24 28 25 29 BETTER_AUTH_SECRET=<secret> 26 30 BETTER_AUTH_URL=<base_url> 31 + 32 + NUXT_PUBLIC_SUBSCRIPTIONS_PUBLIC_KEY=<public_key> 33 + SUBSCRIPTIONS_PRIVATE_KEY=<private_key> 34 + SUBSCRIPTIONS_MAIL=<mailto:you@example.com> 27 35 ```
+50
server/utils/subscriptions.ts
··· 1 + import type {Todo} from "#shared/types"; 2 + import webpush from "web-push"; 3 + 4 + export async function sendMsg(): Promise<void> { 5 + const keys = await useStorage().getKeys(); 6 + const filteredKeys = keys.filter((key) => key.startsWith("todos:")); 7 + 8 + for (const key of filteredKeys) { 9 + const todos = await useStorage().get<Todo[]>(key); 10 + 11 + for (const todo of todos!) { 12 + if (todo.time?.type === "point") { 13 + const date = new Date(todo.time.time); 14 + const now = new Date(); 15 + 16 + const sameMinute = 17 + date.getUTCFullYear() === now.getUTCFullYear() && 18 + date.getUTCMonth() === now.getUTCMonth() && 19 + date.getUTCDate() === now.getUTCDate() && 20 + date.getUTCHours() === now.getUTCHours() && 21 + date.getUTCMinutes() === now.getUTCMinutes(); 22 + 23 + if (sameMinute) { 24 + await sendNotification( 25 + key.substring(6), 26 + todo.title, 27 + todo.note, 28 + ); 29 + } 30 + } 31 + } 32 + } 33 + } 34 + 35 + async function sendNotification(userId: string, title: string, body: string) { 36 + const subscriptions = await Subscriptions.getAll(userId); 37 + 38 + const payload = JSON.stringify({ 39 + title: title, 40 + body: body, 41 + }); 42 + 43 + for (const sub of subscriptions) { 44 + try { 45 + await webpush.sendNotification(sub, payload); 46 + } catch (err) { 47 + console.error("Push failed", err); 48 + } 49 + } 50 + }

History

1 round 0 comments
sign up or login to add to the discussion
benni043.tngl.sh submitted #0
9 commits
expand
sending messages works
exists check update
added nitro scheduled tasks
lints
works
changes
lints
lints
fixes
merge conflicts detected
expand
  • app/pages/index.vue:24
  • package.json:22
  • pnpm-lock.yaml:74
expand 0 comments